- Created By
-
-
-
+
+
diff --git a/napcat.webui/src/styles/globals.css b/napcat.webui/src/styles/globals.css
index adae56c1..bbbcae09 100644
--- a/napcat.webui/src/styles/globals.css
+++ b/napcat.webui/src/styles/globals.css
@@ -1,5 +1,5 @@
@import url('./fonts.css');
-
+@import url('./text.css');
@tailwind base;
@tailwind components;
@tailwind utilities;
diff --git a/napcat.webui/src/styles/text.css b/napcat.webui/src/styles/text.css
new file mode 100644
index 00000000..debb19e6
--- /dev/null
+++ b/napcat.webui/src/styles/text.css
@@ -0,0 +1,34 @@
+@layer base {
+ .shiny-text {
+ @apply text-pink-400 text-opacity-60;
+ background-size: 200% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ animation: shine 5s linear infinite;
+ }
+ .shiny-text {
+ background-image: linear-gradient(
+ 120deg,
+ rgba(255, 50, 50, 0) 40%,
+ rgba(255, 76, 76, 0.8) 50%,
+ rgba(255, 50, 50, 0) 60%
+ );
+ }
+ .dark .shiny-text {
+ background-image: linear-gradient(
+ 120deg,
+ rgba(255, 255, 255, 0) 40%,
+ rgba(206, 21, 21, 0.8) 50%,
+ rgba(255, 255, 255, 0) 60%
+ );
+ }
+
+ @keyframes shine {
+ 0% {
+ background-position: 100%;
+ }
+ 100% {
+ background-position: -100%;
+ }
+ }
+}
diff --git a/src/common/store.ts b/src/common/store.ts
new file mode 100644
index 00000000..a1dcd851
--- /dev/null
+++ b/src/common/store.ts
@@ -0,0 +1,190 @@
+export type StoreValueType = string | number | boolean | object | null;
+
+export type StoreValue = {
+ value: T;
+ expiresAt?: number;
+};
+
+class Store {
+ // 使用Map存储键值对
+ private store: Map;
+ // 定时清理器
+ private cleanerTimer: NodeJS.Timeout;
+ // 用于分批次扫描的游标
+ private scanCursor: number = 0;
+
+ /**
+ * Store
+ * @param cleanInterval 清理间隔
+ * @param scanLimit 扫描限制(每次最多检查的键数)
+ */
+ constructor(
+ cleanInterval: number = 1000, // 默认1秒执行一次
+ private scanLimit: number = 100 // 每次最多检查100个键
+ ) {
+ this.store = new Map();
+ this.cleanerTimer = setInterval(() => this.cleanupExpired(), cleanInterval);
+ }
+
+ /**
+ * 设置键值对
+ * @param key 键
+ * @param value 值
+ * @param ttl 过期时间
+ * @returns void
+ * @example store.set('key', 'value', 60)
+ */
+ set(key: string, value: T, ttl?: number): void {
+ if (ttl && ttl <= 0) {
+ this.del(key);
+ return;
+ }
+ const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
+ this.store.set(key, { value, expiresAt });
+ }
+
+ /**
+ * 清理过期键
+ */
+ private cleanupExpired(): void {
+ const now = Date.now();
+ const keys = Array.from(this.store.keys());
+ let scanned = 0;
+
+ // 分批次扫描
+ while (scanned < this.scanLimit && this.scanCursor < keys.length) {
+ const key = keys[this.scanCursor++];
+ const entry = this.store.get(key)!;
+
+ if (entry.expiresAt && entry.expiresAt < now) {
+ this.store.delete(key);
+ }
+
+ scanned++;
+ }
+
+ // 重置游标(环形扫描)
+ if (this.scanCursor >= keys.length) {
+ this.scanCursor = 0;
+ }
+ }
+
+ /**
+ * 获取键值
+ * @param key 键
+ * @returns T | null
+ * @example store.get('key')
+ */
+ get(key: string): T | null {
+ this.checkKeyExpiry(key); // 每次访问都检查
+ const entry = this.store.get(key);
+ return entry ? (entry.value as T) : null;
+ }
+
+ /**
+ * 检查键是否过期
+ * @param key 键
+ */
+ private checkKeyExpiry(key: string): void {
+ const entry = this.store.get(key);
+ if (entry?.expiresAt && entry.expiresAt < Date.now()) {
+ this.store.delete(key);
+ }
+ }
+
+ /**
+ * 检查键是否存在
+ * @param keys 键
+ * @returns number
+ * @example store.exists('key1', 'key2')
+ */
+ exists(...keys: string[]): number {
+ return keys.filter((key) => {
+ this.checkKeyExpiry(key);
+ return this.store.has(key);
+ }).length;
+ }
+
+ /**
+ * 关闭存储器
+ */
+ shutdown(): void {
+ clearInterval(this.cleanerTimer);
+ this.store.clear();
+ }
+
+ /**
+ * 删除键
+ * @param keys 键
+ * @returns number
+ * @example store.del('key1', 'key2')
+ */
+ del(...keys: string[]): number {
+ return keys.reduce((count, key) => (this.store.delete(key) ? count + 1 : count), 0);
+ }
+
+ /**
+ * 设置键的过期时间
+ * @param key 键
+ * @param seconds 过期时间(秒)
+ * @returns boolean
+ * @example store.expire('key', 60)
+ */
+ expire(key: string, seconds: number): boolean {
+ const entry = this.store.get(key);
+ if (!entry) return false;
+
+ entry.expiresAt = Date.now() + seconds * 1000;
+ return true;
+ }
+
+ /**
+ * 获取键的过期时间
+ * @param key 键
+ * @returns number | null
+ * @example store.ttl('key')
+ */
+ ttl(key: string): number | null {
+ const entry = this.store.get(key);
+ if (!entry) return null;
+
+ if (!entry.expiresAt) return -1;
+ const remaining = entry.expiresAt - Date.now();
+ return remaining > 0 ? Math.floor(remaining / 1000) : -2;
+ }
+
+ /**
+ * 键值数字递增
+ * @param key 键
+ * @returns number
+ * @example store.incr('key')
+ */
+ incr(key: string): number {
+ const current = this.get(key);
+
+ if (current === null) {
+ this.set(key, 1);
+ return 1;
+ }
+
+ let numericValue: number;
+ if (typeof current === 'number') {
+ numericValue = current;
+ } else if (typeof current === 'string') {
+ if (!/^-?\d+$/.test(current)) {
+ throw new Error('ERR value is not an integer');
+ }
+ numericValue = parseInt(current, 10);
+ } else {
+ throw new Error('ERR value is not an integer');
+ }
+
+ const newValue = numericValue + 1;
+ this.set(key, newValue);
+ return newValue;
+ }
+}
+
+const store = new Store();
+
+export default store;
diff --git a/src/webui/src/api/Auth.ts b/src/webui/src/api/Auth.ts
index b29f743c..d347f1a7 100644
--- a/src/webui/src/api/Auth.ts
+++ b/src/webui/src/api/Auth.ts
@@ -13,12 +13,15 @@ export const LoginHandler: RequestHandler = async (req, res) => {
const WebUiConfigData = await WebUiConfig.GetWebUIConfig();
// 获取请求体中的token
const { token } = req.body;
+ // 获取客户端IP
+ const clientIP = req.ip || req.socket.remoteAddress || '';
+
// 如果token为空,返回错误信息
if (isEmpty(token)) {
return sendError(res, 'token is empty');
}
// 检查登录频率
- if (!WebUiDataRuntime.checkLoginRate(WebUiConfigData.loginRate)) {
+ if (!WebUiDataRuntime.checkLoginRate(clientIP, WebUiConfigData.loginRate)) {
return sendError(res, 'login rate limit');
}
//验证config.token是否等于token
@@ -26,7 +29,7 @@ export const LoginHandler: RequestHandler = async (req, res) => {
return sendError(res, 'token is invalid');
}
// 签发凭证
- const signCredential = Buffer.from(JSON.stringify(await AuthHelper.signCredential(WebUiConfigData.token))).toString(
+ const signCredential = Buffer.from(JSON.stringify(AuthHelper.signCredential(WebUiConfigData.token))).toString(
'base64'
);
// 返回成功信息
@@ -36,9 +39,16 @@ export const LoginHandler: RequestHandler = async (req, res) => {
};
// 退出登录
-export const LogoutHandler: RequestHandler = (_, res) => {
- // TODO: 这玩意无状态销毁个灯 得想想办法
- return sendSuccess(res, null);
+export const LogoutHandler: RequestHandler = async (req, res) => {
+ const authorization = req.headers.authorization;
+ try {
+ const CredentialBase64: string = authorization?.split(' ')[1] as string;
+ const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
+ AuthHelper.revokeCredential(Credential);
+ return sendSuccess(res, 'Logged out successfully');
+ } catch (e) {
+ return sendError(res, 'Logout failed');
+ }
};
// 检查登录状态
@@ -53,25 +63,41 @@ export const checkHandler: RequestHandler = async (req, res) => {
const CredentialBase64: string = authorization?.split(' ')[1] as string;
// 解析凭证
const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
+
+ // 检查凭证是否已被注销
+ if (AuthHelper.isCredentialRevoked(Credential)) {
+ return sendError(res, 'Token has been revoked');
+ }
+
// 验证凭证是否在一小时内有效
- await AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential);
+ const valid = AuthHelper.validateCredentialWithinOneHour(WebUiConfigData.token, Credential);
// 返回成功信息
- return sendSuccess(res, null);
+ if (valid) return sendSuccess(res, null);
+ // 返回错误信息
+ return sendError(res, 'Authorization Failed');
} catch (e) {
// 返回错误信息
- return sendError(res, 'Authorization Faild');
+ return sendError(res, 'Authorization Failed');
}
};
// 修改密码(token)
export const UpdateTokenHandler: RequestHandler = async (req, res) => {
const { oldToken, newToken } = req.body;
+ const authorization = req.headers.authorization;
if (isEmpty(oldToken) || isEmpty(newToken)) {
return sendError(res, 'oldToken or newToken is empty');
}
try {
+ // 注销当前的Token
+ if (authorization) {
+ const CredentialBase64: string = authorization.split(' ')[1];
+ const Credential = JSON.parse(Buffer.from(CredentialBase64, 'base64').toString());
+ AuthHelper.revokeCredential(Credential);
+ }
+
await WebUiConfig.UpdateToken(oldToken, newToken);
return sendSuccess(res, 'Token updated successfully');
} catch (e: any) {
diff --git a/src/webui/src/helper/Data.ts b/src/webui/src/helper/Data.ts
index 8f1091c7..bafc5ba5 100644
--- a/src/webui/src/helper/Data.ts
+++ b/src/webui/src/helper/Data.ts
@@ -1,5 +1,7 @@
import type { LoginRuntimeType } from '../types/data';
import packageJson from '../../../../package.json';
+import store from '@/common/store';
+
const LoginRuntime: LoginRuntimeType = {
LoginCurrentTime: Date.now(),
LoginCurrentRate: 0,
@@ -26,15 +28,22 @@ const LoginRuntime: LoginRuntimeType = {
};
export const WebUiDataRuntime = {
- checkLoginRate(RateLimit: number): boolean {
- LoginRuntime.LoginCurrentRate++;
- //console.log(RateLimit, LoginRuntime.LoginCurrentRate, Date.now() - LoginRuntime.LoginCurrentTime);
- if (Date.now() - LoginRuntime.LoginCurrentTime > 1000 * 60) {
- LoginRuntime.LoginCurrentRate = 0; //超出时间重置限速
- LoginRuntime.LoginCurrentTime = Date.now();
+ checkLoginRate(ip: string, RateLimit: number): boolean {
+ const key = `login_rate:${ip}`;
+ const count = store.get(key) || 0;
+
+ if (count === 0) {
+ // 第一次访问,设置计数器为1,并设置60秒过期
+ store.set(key, 1, 60);
return true;
}
- return LoginRuntime.LoginCurrentRate <= RateLimit;
+
+ if (count >= RateLimit) {
+ return false;
+ }
+
+ store.incr(key);
+ return true;
},
getQQLoginStatus(): LoginRuntimeType['QQLoginStatus'] {
@@ -108,5 +117,5 @@ export const WebUiDataRuntime = {
getQQVersion() {
return LoginRuntime.QQVersion;
- }
+ },
};
diff --git a/src/webui/src/helper/SignToken.ts b/src/webui/src/helper/SignToken.ts
index 72860060..50865b19 100644
--- a/src/webui/src/helper/SignToken.ts
+++ b/src/webui/src/helper/SignToken.ts
@@ -1,5 +1,5 @@
import crypto from 'crypto';
-
+import store from '@/common/store';
export class AuthHelper {
private static readonly secretKey = Math.random().toString(36).slice(2);
@@ -8,7 +8,7 @@ export class AuthHelper {
* @param token 待签名的凭证字符串。
* @returns 签名后的凭证对象。
*/
- public static async signCredential(token: string): Promise {
+ public static signCredential(token: string): WebUiCredentialJson {
const innerJson: WebUiCredentialInnerJson = {
CreatedTime: Date.now(),
TokenEncoded: token,
@@ -23,7 +23,7 @@ export class AuthHelper {
* @param credentialJson 凭证的JSON对象。
* @returns 布尔值,表示凭证是否有效。
*/
- public static async checkCredential(credentialJson: WebUiCredentialJson): Promise {
+ public static checkCredential(credentialJson: WebUiCredentialJson): boolean {
try {
const jsonString = JSON.stringify(credentialJson.Data);
const calculatedHmac = crypto
@@ -42,19 +42,47 @@ export class AuthHelper {
* @param credentialJson 已签名的凭证JSON对象。
* @returns 布尔值,表示凭证是否有效且token匹配。
*/
- public static async validateCredentialWithinOneHour(
- token: string,
- credentialJson: WebUiCredentialJson
- ): Promise {
- const isValid = await AuthHelper.checkCredential(credentialJson);
+ public static validateCredentialWithinOneHour(token: string, credentialJson: WebUiCredentialJson): boolean {
+ // 首先检查凭证是否被篡改
+ const isValid = AuthHelper.checkCredential(credentialJson);
if (!isValid) {
return false;
}
+ // 检查凭证是否在黑名单中
+ if (AuthHelper.isCredentialRevoked(credentialJson)) {
+ return false;
+ }
+
const currentTime = Date.now() / 1000;
const createdTime = credentialJson.Data.CreatedTime;
const timeDifference = currentTime - createdTime;
return timeDifference <= 3600 && credentialJson.Data.TokenEncoded === token;
}
+
+ /**
+ * 注销指定的Token凭证
+ * @param credentialJson 凭证JSON对象
+ * @returns void
+ */
+ public static revokeCredential(credentialJson: WebUiCredentialJson): void {
+ const jsonString = JSON.stringify(credentialJson.Data);
+ const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
+
+ // 将已注销的凭证添加到黑名单中,有效期1小时
+ store.set(`revoked:${hmac}`, true, 3600);
+ }
+
+ /**
+ * 检查凭证是否已被注销
+ * @param credentialJson 凭证JSON对象
+ * @returns 布尔值,表示凭证是否已被注销
+ */
+ public static isCredentialRevoked(credentialJson: WebUiCredentialJson): boolean {
+ const jsonString = JSON.stringify(credentialJson.Data);
+ const hmac = crypto.createHmac('sha256', AuthHelper.secretKey).update(jsonString, 'utf8').digest('hex');
+
+ return store.exists(`revoked:${hmac}`) > 0;
+ }
}
diff --git a/src/webui/src/middleware/auth.ts b/src/webui/src/middleware/auth.ts
index 64e21b50..67d73ecd 100644
--- a/src/webui/src/middleware/auth.ts
+++ b/src/webui/src/middleware/auth.ts
@@ -32,7 +32,7 @@ export async function auth(req: Request, res: Response, next: NextFunction) {
// 获取配置
const config = await WebUiConfig.GetWebUIConfig();
// 验证凭证在1小时内有效且token与原始token相同
- const credentialJson = await AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
+ const credentialJson = AuthHelper.validateCredentialWithinOneHour(config.token, Credential);
if (credentialJson) {
// 通过验证
return next();