From 9f3825cccb30f5d28061e76de0c76af95352afd1 Mon Sep 17 00:00:00 2001 From: vicanso Date: Tue, 26 Nov 2024 21:24:18 +0800 Subject: [PATCH] refactor: adjust admin login page --- src/http_extra/http_header.rs | 18 +------- src/plugin/admin.rs | 66 +++++++++++++++++++++-------- web/src/components/header.tsx | 8 +++- web/src/helpers/http-error.ts | 1 + web/src/helpers/request.ts | 11 +++++ web/src/helpers/util.ts | 10 +++++ web/src/i18n/en.ts | 7 +++ web/src/pages/Login.tsx | 80 +++++++++++++++++++++++++++++++++++ web/src/routers.tsx | 10 +++++ web/src/states/token.ts | 17 ++++++++ web/tsconfig.app.tsbuildinfo | 2 +- 11 files changed, 193 insertions(+), 37 deletions(-) create mode 100644 web/src/pages/Login.tsx create mode 100644 web/src/states/token.ts diff --git a/src/http_extra/http_header.rs b/src/http_extra/http_header.rs index d155a5db..12de7567 100644 --- a/src/http_extra/http_header.rs +++ b/src/http_extra/http_header.rs @@ -176,13 +176,6 @@ pub static HTTP_HEADER_NO_STORE: Lazy = Lazy::new(|| { ) }); -pub static HTTP_HEADER_WWW_AUTHENTICATE: Lazy = Lazy::new(|| { - ( - header::WWW_AUTHENTICATE, - HeaderValue::from_str(r###"Basic realm="Pingap""###).unwrap(), - ) -}); - pub static HTTP_HEADER_NO_CACHE: Lazy = Lazy::new(|| { ( header::CACHE_CONTROL, @@ -228,7 +221,7 @@ mod tests { convert_header_value, convert_headers, HTTP_HEADER_CONTENT_HTML, HTTP_HEADER_CONTENT_JSON, HTTP_HEADER_NAME_X_REQUEST_ID, HTTP_HEADER_NO_CACHE, HTTP_HEADER_NO_STORE, - HTTP_HEADER_TRANSFER_CHUNKED, HTTP_HEADER_WWW_AUTHENTICATE, + HTTP_HEADER_TRANSFER_CHUNKED, }; use crate::state::State; use http::HeaderValue; @@ -465,15 +458,6 @@ mod tests { ) ); - assert_eq!( - r#"www-authenticate: Basic realm="Pingap""#, - format!( - "{}: {}", - HTTP_HEADER_WWW_AUTHENTICATE.0.to_string(), - HTTP_HEADER_WWW_AUTHENTICATE.1.to_str().unwrap_or_default() - ) - ); - assert_eq!( "cache-control: private, no-cache", format!( diff --git a/src/plugin/admin.rs b/src/plugin/admin.rs index 3f7cefb1..1e49b103 100644 --- a/src/plugin/admin.rs +++ b/src/plugin/admin.rs @@ -26,7 +26,7 @@ use crate::config::{ PingapConf, CATEGORY_LOCATION, CATEGORY_PLUGIN, CATEGORY_SERVER, CATEGORY_UPSTREAM, }; -use crate::http_extra::{HttpResponse, HTTP_HEADER_WWW_AUTHENTICATE}; +use crate::http_extra::HttpResponse; use crate::limit::TtlLruLimit; use crate::proxy::get_certificate_info_list; use crate::state::{ @@ -40,13 +40,16 @@ use bytes::{BufMut, BytesMut}; use flate2::write::GzEncoder; use flate2::Compression; use hex::encode; +use hex::ToHex; use http::Method; use http::{header, HeaderValue, StatusCode}; use pingora::http::RequestHeader; use pingora::proxy::Session; +use regex::Regex; use rust_embed::EmbeddedFile; use rust_embed::RustEmbed; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::io::Write; use std::time::Duration; @@ -114,7 +117,7 @@ impl From for HttpResponse { pub struct AdminServe { pub path: String, - pub authorizations: Vec>, + pub authorizations: Vec<(String, String)>, pub plugin_step: PluginStep, hash_value: String, ip_fail_limit: TtlLruLimit, @@ -167,11 +170,16 @@ impl TryFrom<&PluginConf> for AdminServe { if item.is_empty() { continue; } - let _ = base64_decode(item).map_err(|e| Error::Base64Decode { - category: PluginCategory::BasicAuth.to_string(), - source: e, - })?; - authorizations.push(format!("Basic {item}").as_bytes().to_vec()); + let data = + base64_decode(item).map_err(|e| Error::Base64Decode { + category: PluginCategory::BasicAuth.to_string(), + source: e, + })?; + if let Some((user, pass)) = + std::string::String::from_utf8_lossy(&data).split_once(':') + { + authorizations.push((user.to_string(), pass.to_string())); + } } let mut ip_fail_limit = get_int_conf(value, "ip_fail_limit"); if ip_fail_limit <= 0 { @@ -232,12 +240,35 @@ impl AdminServe { if self.authorizations.is_empty() { return true; } + let path = req_header.uri.path(); + if path.len() <= 1 + || Regex::new(r#".(js|css)$"#).unwrap().is_match(path) + { + return true; + } let value = util::get_req_header_value(req_header, "Authorization") .unwrap_or_default(); if value.is_empty() { return false; } - self.authorizations.contains(&value.as_bytes().to_vec()) + let Some((token, ts)) = value.split_once(':') else { + return false; + }; + let offset = util::now().as_secs() as i64 + - ts.parse::().unwrap_or_default(); + if offset.abs() > 48 * 3600 { + return false; + } + + for (user, pass) in self.authorizations.iter() { + let mut hasher = Sha256::new(); + hasher.update(format!("{user}:{pass}:{ts}").as_bytes()); + let hash256 = hasher.finalize(); + if hash256.encode_hex::() == token { + return true; + } + } + false } async fn load_config( &self, @@ -440,14 +471,6 @@ impl Plugin for AdminServe { return Ok(None); } let header = session.req_header_mut(); - if !self.auth_validate(header) { - self.ip_fail_limit.inc(&ip).await; - return Ok(Some(HttpResponse { - status: StatusCode::UNAUTHORIZED, - headers: Some(vec![HTTP_HEADER_WWW_AUTHENTICATE.clone()]), - ..Default::default() - })); - } let path = header.uri.path(); let mut new_path = path.substring(self.path.len(), path.len()).to_string(); @@ -458,6 +481,13 @@ impl Plugin for AdminServe { if let Ok(uri) = new_path.parse::() { header.set_uri(uri); } + if !self.auth_validate(header) { + self.ip_fail_limit.inc(&ip).await; + return Ok(Some(HttpResponse { + status: StatusCode::UNAUTHORIZED, + ..Default::default() + })); + } let (method, mut path) = get_method_path(session); let api_prefix = "/api"; @@ -614,11 +644,11 @@ mod tests { ) .unwrap(); assert_eq!( - "Basic YWRtaW46MTIzMTIz,Basic cGluZ2FwOjEyMzEyMw==", + "admin:123123,pingap:123123", params .authorizations .iter() - .map(|item| std::string::String::from_utf8_lossy(item)) + .map(|item| format!("{}:{}", item.0, item.1)) .collect::>() .join(",") ); diff --git a/web/src/components/header.tsx b/web/src/components/header.tsx index 296dbeeb..b8b8d01c 100644 --- a/web/src/components/header.tsx +++ b/web/src/components/header.tsx @@ -12,7 +12,7 @@ import { AudioWaveform, ClipboardCopy, } from "lucide-react"; -import { goToConfig, goToHome } from "@/routers"; +import { goToConfig, goToHome, goToLogin } from "@/routers"; import { useTheme } from "@/components/theme-provider"; import { Button } from "@/components/ui/button"; import { @@ -40,6 +40,7 @@ import { } from "@/components/ui/popover"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import request from "@/helpers/request"; +import HTTPError from "@/helpers/http-error"; export function MainHeader({ className, @@ -108,6 +109,11 @@ export function MainHeader({ title: t("fetchFail"), description: formatError(err), }); + + const status = ((err as HTTPError)?.status || 0) as number; + if (status == 401 || status === 403) { + goToLogin(); + } } }, []); const zhLang = "zh"; diff --git a/web/src/helpers/http-error.ts b/web/src/helpers/http-error.ts index 7312c632..d1ec7346 100644 --- a/web/src/helpers/http-error.ts +++ b/web/src/helpers/http-error.ts @@ -1,6 +1,7 @@ class HTTPError extends Error { // error message message: string; + status?: number; exception?: boolean; category?: string; constructor(message: string) { diff --git a/web/src/helpers/request.ts b/web/src/helpers/request.ts index 5eff8a0f..8f179c5c 100644 --- a/web/src/helpers/request.ts +++ b/web/src/helpers/request.ts @@ -1,6 +1,8 @@ import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; import HTTPError from "./http-error"; +import { getLoginToken, removeLoginToken } from "@/states/token"; + const requestedAt = "X-Requested-At"; const request = axios.create({ // 默认超时为10秒 @@ -9,6 +11,10 @@ const request = axios.create({ request.interceptors.request.use( (config) => { + const token = getLoginToken(); + if (token) { + config.headers["Authorization"] = token; + } // 对请求的query部分清空值 if (config.params) { Object.keys(config.params).forEach((element) => { @@ -74,7 +80,12 @@ request.interceptors.response.use( }, (err) => { const { response } = err; + const he = new HTTPError("Unknown error"); + he.status = response.status; + if (he.status == 401) { + removeLoginToken(); + } if (timeoutErrorCodes.includes(err.code)) { he.category = "timeout"; he.message = "Request timeout"; diff --git a/web/src/helpers/util.ts b/web/src/helpers/util.ts index 98252bdc..e73d68c8 100644 --- a/web/src/helpers/util.ts +++ b/web/src/helpers/util.ts @@ -78,3 +78,13 @@ export function formatLabel(label: string) { } return label; } + +export async function sha256(message: string) { + const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array + const hashBuffer = await window.crypto.subtle.digest("SHA-256", msgUint8); // hash the message + const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); // convert bytes to hex string + return hashHex; +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 8d67aaed..3c535c11 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -40,6 +40,13 @@ export default { storage: "Storage", searchPlaceholder: "Input the keyword", }, + login: { + title: "Login", + description: "Please enter the administrator account and password.", + account: "Account", + password: "Password", + submit: "Login", + }, home: { dashboard: "Dashboard", basic: "Basic Information", diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 00000000..7948e246 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -0,0 +1,80 @@ +import { useI18n } from "@/i18n"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import React from "react"; +import { saveLoginToken } from "@/states/token"; +import useBasicState from "@/states/basic"; +import { goToHome } from "@/routers"; +import useConfigState from "@/states/config"; + +export default function Login() { + const loginI18n = useI18n("login"); + const [account, setAccount] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [fetchBasicInfo] = useBasicState((state) => [state.fetch]); + const [fetchConfig] = useConfigState((state) => [state.fetch]); + const handleLogin = async () => { + try { + await saveLoginToken(account, password); + await fetchBasicInfo(); + await fetchConfig(); + goToHome(); + } catch (err) { + console.error(err); + } + }; + return ( +
+
+ + + {loginI18n("title")} + {loginI18n("description")} + + +
+ + { + setAccount(e.target.value.trim()); + }} + /> +
+
+ + { + setPassword(e.target.value.trim()); + }} + /> +
+
+ + + +
+
+
+ ); +} diff --git a/web/src/routers.tsx b/web/src/routers.tsx index 73bf9df3..ed12248b 100644 --- a/web/src/routers.tsx +++ b/web/src/routers.tsx @@ -9,6 +9,7 @@ import Plugins from "@/pages/Plugins"; import Certificates from "@/pages/Certificates"; import Config from "@/pages/Config"; import Storages from "@/pages/Storages"; +import Login from "@/pages/Login"; export const HOME = "/"; export const BASIC = "/basic"; @@ -19,6 +20,7 @@ export const PLUGINS = "/plugins"; export const CERTIFICATES = "/certificates"; export const STORAGES = "/storages"; export const CONFIG = "/config"; +export const LOGIN = "/login"; const router = createHashRouter([ { @@ -60,6 +62,10 @@ const router = createHashRouter([ path: STORAGES, element: , }, + { + path: LOGIN, + element: , + }, ], }, ]); @@ -73,3 +79,7 @@ export function goToHome() { export function goToConfig() { router.navigate(CONFIG); } + +export function goToLogin() { + router.navigate(LOGIN); +} diff --git a/web/src/states/token.ts b/web/src/states/token.ts new file mode 100644 index 00000000..77c972d0 --- /dev/null +++ b/web/src/states/token.ts @@ -0,0 +1,17 @@ +import { sha256 } from "@/helpers/util"; + +const PINGAP_LOGIN_TOKEN = "pingap:loginToken"; + +export async function saveLoginToken(account: string, password: string) { + const now = Math.floor(Date.now() / 1000); + const token = await sha256(`${account}:${password}:${now}`); + window.localStorage.setItem(PINGAP_LOGIN_TOKEN, `${token}:${now}`); +} + +export function getLoginToken() { + return window.localStorage.getItem(PINGAP_LOGIN_TOKEN) || ""; +} + +export function removeLoginToken() { + window.localStorage.removeItem(PINGAP_LOGIN_TOKEN); +} diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index 5f2a16db..4811d827 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/constants.ts","./src/main.tsx","./src/routers.tsx","./src/vite-env.d.ts","./src/components/combined-auths.tsx","./src/components/ex-form.tsx","./src/components/header.tsx","./src/components/inputs.tsx","./src/components/kv-inputs.tsx","./src/components/loading.tsx","./src/components/multi-select.tsx","./src/components/nav.tsx","./src/components/sidebar-nav.tsx","./src/components/sort-checkboxs.tsx","./src/components/theme-provider.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/tooltip.tsx","./src/helpers/http-error.ts","./src/helpers/request.ts","./src/helpers/util.ts","./src/hooks/use-toast.ts","./src/i18n/en.ts","./src/i18n/index.ts","./src/i18n/zh.ts","./src/lib/utils.ts","./src/pages/basic.tsx","./src/pages/certificates.tsx","./src/pages/config.tsx","./src/pages/home.tsx","./src/pages/locations.tsx","./src/pages/plugins.tsx","./src/pages/root.tsx","./src/pages/servers.tsx","./src/pages/storages.tsx","./src/pages/upstreams.tsx","./src/states/basic.ts","./src/states/config.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/constants.ts","./src/main.tsx","./src/routers.tsx","./src/vite-env.d.ts","./src/components/combined-auths.tsx","./src/components/ex-form.tsx","./src/components/header.tsx","./src/components/inputs.tsx","./src/components/kv-inputs.tsx","./src/components/loading.tsx","./src/components/multi-select.tsx","./src/components/nav.tsx","./src/components/sidebar-nav.tsx","./src/components/sort-checkboxs.tsx","./src/components/theme-provider.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/tooltip.tsx","./src/helpers/http-error.ts","./src/helpers/request.ts","./src/helpers/util.ts","./src/hooks/use-toast.ts","./src/i18n/en.ts","./src/i18n/index.ts","./src/i18n/zh.ts","./src/lib/utils.ts","./src/pages/basic.tsx","./src/pages/certificates.tsx","./src/pages/config.tsx","./src/pages/home.tsx","./src/pages/locations.tsx","./src/pages/login.tsx","./src/pages/plugins.tsx","./src/pages/root.tsx","./src/pages/servers.tsx","./src/pages/storages.tsx","./src/pages/upstreams.tsx","./src/states/basic.ts","./src/states/config.ts","./src/states/token.ts"],"version":"5.6.3"} \ No newline at end of file