mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2025-07-19 12:03:37 +00:00
feat: 登录状态机
This commit is contained in:
190
src/common/store.ts
Normal file
190
src/common/store.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
export type StoreValueType = string | number | boolean | object | null;
|
||||
|
||||
export type StoreValue<T extends StoreValueType = StoreValueType> = {
|
||||
value: T;
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
class Store {
|
||||
// 使用Map存储键值对
|
||||
private store: Map<string, StoreValue>;
|
||||
// 定时清理器
|
||||
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<T extends StoreValueType>(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<T extends StoreValueType>(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<StoreValueType>(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;
|
@@ -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) {
|
||||
|
@@ -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<number>(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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@@ -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<WebUiCredentialJson> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@@ -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();
|
||||
|
Reference in New Issue
Block a user