Web Ui Autrhentication

This commit is contained in:
euzu
2024-05-03 01:47:41 +02:00
parent 68d87ff0da
commit ae9be38fbd
28 changed files with 525 additions and 136 deletions
+6 -1
View File
@@ -14,7 +14,12 @@
* Mapper can now set `epg_channel_id`.
* Added environment variables for User Credentials `username`, `password` and `token` in format `${env:<EnvVarName>}` where `<EnvVarName>` 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
Generated
+23
View File
@@ -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"
+2
View File
@@ -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"
+1 -35
View File
@@ -3,44 +3,10 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="apple-touch-icon" sizes="57x57" href="%PUBLIC_URL%/assets/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="%PUBLIC_URL%/assets/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="%PUBLIC_URL%/assets/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="%PUBLIC_URL%/assets/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="%PUBLIC_URL%/assets/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="%PUBLIC_URL%/assets/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="%PUBLIC_URL%/assets/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="%PUBLIC_URL%/assets/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/assets/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/assets/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/assets/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="%PUBLIC_URL%/assets/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/assets/favicon-16x16.png">
<!-- link rel="manifest" href="/manifest.json" -->
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<!-- link rel="manifest" href="%PUBLIC_URL%/manifest.json" / -->
<title>m3u-filter</title>
</head>
<body>
+6
View File
@@ -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<T>(query: string, url?: string): Observable<T>;
@@ -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;
}
+22
View File
@@ -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<TokenResponse>;
refresh(): Observable<TokenResponse>
}
export class DefaultAuthApiService extends DefaultApiService implements AuthApiService {
authenticate(username: string, password: string): Observable<{ token: string }> {
return this.post<TokenResponse>('/token', {username, password}, AUTH_API_PATH);
}
refresh(): Observable<TokenResponse> {
return this.post<TokenResponse>('/refresh', {}, AUTH_API_PATH);
}
}
+1 -1
View File
@@ -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) {
@@ -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<boolean>(false);
useEffect(() => {
const sub = services.auth().authChannel().subscribe({
next: (auth) => setAuthenticated(auth),
error: () => setAuthenticated(false),
})
return () => sub.unsubscribe();
}, [services]);
return <>{authenticated ? <App/> : <Login/>}</>
}
+43
View File
@@ -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;
}
}
}
+50
View File
@@ -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<HTMLInputElement>();
const passwordRef = useRef<HTMLInputElement>();
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 <div className={'login'}>
<div className={'title'}>Login to m3u-filter</div>
<form>
<div className="login__form">
<input ref={usernameRef} type="text" name="username" placeholder="username"/>
<input ref={passwordRef} type="password" name="password" placeholder="password"
onKeyDown={handleKeyDown}/>
<button type="button" className="btn" onClick={handleLogin}>Login</button>
<span className={authorized ? '' : 'hidden'}>Failed to login</span>
</div>
</form>
</div>
}
+4 -3
View File
@@ -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( <SnackbarProvider maxSnack={3} autoHideDuration={1500} anchorOrigin={ ({vertical: 'top', horizontal: 'center'}) }>
root.render(
<SnackbarProvider maxSnack={3} autoHideDuration={1500} anchorOrigin={ ({vertical: 'top', horizontal: 'center'}) }>
<ServiceProvider>
<App/>
<Authentication/>
</ServiceProvider>
</SnackbarProvider>
);
+36
View File
@@ -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<boolean>(1);
constructor(private authApiService: AuthApiService = new DefaultAuthApiService()) {
this.subject.next(false);
}
authChannel(): Observable<boolean> {
return this.subject;
}
authenticate(username: string, password: string): Observable<boolean> {
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;
}
}
+8
View File
@@ -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();
+4 -4
View File
@@ -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<AppState>) -> Option<(UserCredentials, &'a ConfigTarget)> {
app_state: &'a web::Data<AppState>) -> 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<AppState>) -> Option<(UserCredentials, &'a ConfigTarget)> {
pub(crate) fn get_user_target<'a>(api_req: &'a UserApiRequest, app_state: &'a web::Data<AppState>) -> 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(),
+7 -10
View File
@@ -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<Config>, targets: Arc<ProcessTargets>)
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<Config>, targets: Arc<ProcessTargets>)
.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
+4 -3
View File
@@ -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)))
+55 -11
View File
@@ -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<UserCredential>,
app_state: web::Data<AppState>,
) -> 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<AppState>,
) -> std::io::Result<NamedFile> {
let path: PathBuf = [&_app_state.config.api.web_root, "index.html"].iter().collect();
NamedFile::open(path)
credentials: Option<BearerAuth>,
app_state: web::Data<AppState>,
) -> 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<AppState>,
) -> std::io::Result<NamedFile> {
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)));
};
+4 -4
View File
@@ -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") {
+55 -15
View File
@@ -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<String, std::io::Error> {
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<BearerAuth>, secret_key: &[u8]) -> bool {
if let Some(auth) = bearer {
let token = auth.token();
let token_message = decode::<Claims>(&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<BearerAuth>,
) -> Result<ServiceRequest, (Error, ServiceRequest)> {
info!("{:?}", credentials);
// Ok(req)
Err((actix_web::error::ErrorUnauthorized("Unauthorized"), req))
let app_state: &web::Data<AppState> = req.app_data::<web::Data<AppState>>().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<B>(srvres: ServiceResponse<B>) -> actix_web::Result<ErrorHandlerResponse<B>> {
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<B>(srvres: ServiceResponse<B>) -> actix_web::Result<ErrorHandlerResponse<B>> {
// 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))
// }
+2 -1
View File
@@ -1,2 +1,3 @@
pub(crate) mod authenticator;
mod password;
pub(crate) mod password;
pub(crate) mod user;
+44 -24
View File
@@ -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<String> {
// 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
// }
// }
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<String> {
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<String> {
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)
}
}
+18
View File
@@ -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);
}
}
}
+20 -5
View File
@@ -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<String>,
@@ -48,7 +49,6 @@ struct Args {
#[arg(short = 'm', long = "mapping")]
mapping_file: Option<String>,
/// The target to process
#[arg(short = 't', long)]
target: Option<Vec<String>>,
@@ -64,6 +64,11 @@ struct Args {
/// log level
#[arg(short = 'l', long = "log-level", default_missing_value = "info")]
log_level: Option<String>,
/// 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<Config>, targets: Arc<ProcessTargets>) {
fn start_in_server_mode(cfg: Arc<Config>, targets: Arc<ProcessTargets>) {
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);
}
+7 -7
View File
@@ -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<String>,
@@ -56,7 +56,7 @@ pub(crate) struct UserCredentials {
pub server: Option<String>,
}
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<UserCredentials>,
pub credentials: Vec<ProxyUserCredentials>,
}
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()));
+78 -8
View File
@@ -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<Vec<UserCredential>>,
}
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<WebAuthConfig>,
pub messaging: Option<MessagingConfig>,
#[serde(skip_serializing, skip_deserializing)]
pub _api_proxy: Arc<RwLock<Option<ApiProxyConfig>>>,
@@ -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(())
}
+2 -2
View File
@@ -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<String> {
pub(crate) fn rewrite_m3u_playlist(cfg: &Config, target: &ConfigTarget, user: &ProxyUserCredentials) -> Option<String> {
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) {
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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() {