Skip to content

Commit

Permalink
refactor: adjust admin login page
Browse files Browse the repository at this point in the history
  • Loading branch information
vicanso committed Nov 26, 2024
1 parent 2feb080 commit 9f3825c
Show file tree
Hide file tree
Showing 11 changed files with 193 additions and 37 deletions.
18 changes: 1 addition & 17 deletions src/http_extra/http_header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,6 @@ pub static HTTP_HEADER_NO_STORE: Lazy<HttpHeader> = Lazy::new(|| {
)
});

pub static HTTP_HEADER_WWW_AUTHENTICATE: Lazy<HttpHeader> = Lazy::new(|| {
(
header::WWW_AUTHENTICATE,
HeaderValue::from_str(r###"Basic realm="Pingap""###).unwrap(),
)
});

pub static HTTP_HEADER_NO_CACHE: Lazy<HttpHeader> = Lazy::new(|| {
(
header::CACHE_CONTROL,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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!(
Expand Down
66 changes: 48 additions & 18 deletions src/plugin/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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;
Expand Down Expand Up @@ -114,7 +117,7 @@ impl From<EmbeddedStaticFile> for HttpResponse {

pub struct AdminServe {
pub path: String,
pub authorizations: Vec<Vec<u8>>,
pub authorizations: Vec<(String, String)>,
pub plugin_step: PluginStep,
hash_value: String,
ip_fail_limit: TtlLruLimit,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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::<i64>().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::<String>() == token {
return true;
}
}
false
}
async fn load_config(
&self,
Expand Down Expand Up @@ -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();
Expand All @@ -458,6 +481,13 @@ impl Plugin for AdminServe {
if let Ok(uri) = new_path.parse::<http::Uri>() {
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";
Expand Down Expand Up @@ -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::<Vec<_>>()
.join(",")
);
Expand Down
8 changes: 7 additions & 1 deletion web/src/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions web/src/helpers/http-error.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class HTTPError extends Error {
// error message
message: string;
status?: number;
exception?: boolean;
category?: string;
constructor(message: string) {
Expand Down
11 changes: 11 additions & 0 deletions web/src/helpers/request.ts
Original file line number Diff line number Diff line change
@@ -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秒
Expand All @@ -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) => {
Expand Down Expand Up @@ -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";
Expand Down
10 changes: 10 additions & 0 deletions web/src/helpers/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
7 changes: 7 additions & 0 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 80 additions & 0 deletions web/src/pages/Login.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grow lg:border-l overflow-auto p-4">
<div className="flex justify-center mt-10">
<Card className="max-w-xl self-center">
<CardHeader>
<CardTitle>{loginI18n("title")}</CardTitle>
<CardDescription>{loginI18n("description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="space-y-1">
<Label htmlFor="account">{loginI18n("account")}</Label>
<Input
id="account"
autoFocus
onChange={(e) => {
setAccount(e.target.value.trim());
}}
/>
</div>
<div className="space-y-1">
<Label htmlFor="password">{loginI18n("password")}</Label>
<Input
id="password"
type="password"
onChange={(e) => {
setPassword(e.target.value.trim());
}}
/>
</div>
</CardContent>
<CardFooter>
<Button
className="w-[100px]"
onClick={(e) => {
e.preventDefault();
handleLogin();
}}
>
{loginI18n("submit")}
</Button>
</CardFooter>
</Card>
</div>
</div>
);
}
10 changes: 10 additions & 0 deletions web/src/routers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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([
{
Expand Down Expand Up @@ -60,6 +62,10 @@ const router = createHashRouter([
path: STORAGES,
element: <Storages />,
},
{
path: LOGIN,
element: <Login />,
},
],
},
]);
Expand All @@ -73,3 +79,7 @@ export function goToHome() {
export function goToConfig() {
router.navigate(CONFIG);
}

export function goToLogin() {
router.navigate(LOGIN);
}
Loading

0 comments on commit 9f3825c

Please sign in to comment.