feat(auth): 新增系统设置功能
- 实现管理员登录和验证码验证功能 - 添加管理员登录流程,支持通过 Telegram 验证 - 实现登录挑战和会话管理,确保安全性 - 更新前端状态管理,处理登录和登出逻辑 - 增加系统配置管理,支持 Telegram 安全设置 - 新增数据库表以存储登录挑战和会话信息
This commit is contained in:
1 parent
1c9bd91aed
commit
bd76a89ba1
21 files changed
+1820
-78
No files matched your search
@@ -0,0 +1,235 @@
|
||||
import { Context } from "hono";
|
||||
import { OpenAPIRoute } from "chanfana";
|
||||
import { z } from "zod";
|
||||
|
||||
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
||||
import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config";
|
||||
import {
|
||||
createAdminLoginChallenge,
|
||||
createAdminSession,
|
||||
deleteAdminLoginChallenge,
|
||||
getAdminSessionHeaderName,
|
||||
invalidateAdminSession,
|
||||
sendAdminLoginCodeNotification,
|
||||
sendAdminLoginResultNotification,
|
||||
verifyAdminLoginChallenge,
|
||||
} from "./auth_shared";
|
||||
|
||||
const loginStartRequestSchema = z.object({
|
||||
token: z.string().trim().min(1, "管理员令牌不能为空"),
|
||||
});
|
||||
|
||||
const loginStartResponseSchema = z.object({
|
||||
requiresVerification: z.boolean(),
|
||||
challengeId: z.nullable(z.string()),
|
||||
challengeExpiresAt: z.nullable(z.string()),
|
||||
sessionToken: z.nullable(z.string()),
|
||||
sessionExpiresAt: z.nullable(z.string()),
|
||||
});
|
||||
|
||||
const loginVerifyRequestSchema = z.object({
|
||||
challengeId: z.string().trim().min(1, "challengeId 不能为空"),
|
||||
code: z.string().trim().regex(/^\d{6}$/, "验证码必须为 6 位数字"),
|
||||
});
|
||||
|
||||
const buildDirectLoginResponse = async (
|
||||
c: Context<HonoCustomType>
|
||||
) => {
|
||||
const session = await createAdminSession(c);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
requiresVerification: false,
|
||||
challengeId: null,
|
||||
challengeExpiresAt: null,
|
||||
sessionToken: session.sessionToken,
|
||||
sessionExpiresAt: session.expiresAt,
|
||||
},
|
||||
message: "Login successful",
|
||||
} as CommonResponse;
|
||||
};
|
||||
|
||||
export class AdminLoginStartEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ["Admin API"],
|
||||
summary: "Start admin login",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: loginStartRequestSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(loginStartResponseSchema),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const parsedBody = loginStartRequestSchema.safeParse(
|
||||
await c.req.json<{ token: string }>()
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return c.text(parsedBody.error.issues[0]?.message || "管理员令牌不能为空", 400);
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const systemConfig = await getSystemConfig(c);
|
||||
const securityConfig = systemConfig.adminSecurity;
|
||||
const securityEnabled = isTelegramSecurityEnabled(securityConfig);
|
||||
|
||||
if (!c.env.ADMIN_TOKEN || body.token !== c.env.ADMIN_TOKEN) {
|
||||
if (securityEnabled) {
|
||||
await sendAdminLoginResultNotification(
|
||||
securityConfig,
|
||||
c,
|
||||
"failure",
|
||||
"管理员令牌错误"
|
||||
).catch((error) => {
|
||||
console.error("Failed to send Telegram login failure notification:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return c.text("管理员令牌无效", 401);
|
||||
}
|
||||
|
||||
if (!securityEnabled) {
|
||||
return buildDirectLoginResponse(c);
|
||||
}
|
||||
|
||||
const challenge = await createAdminLoginChallenge(c);
|
||||
|
||||
try {
|
||||
await sendAdminLoginCodeNotification(
|
||||
securityConfig,
|
||||
c,
|
||||
challenge.code,
|
||||
challenge.expiresAt
|
||||
);
|
||||
} catch (error) {
|
||||
await deleteAdminLoginChallenge(c, challenge.challengeId);
|
||||
|
||||
return c.text(
|
||||
`Telegram 发送验证码失败:${error instanceof Error ? error.message : "unknown error"}`,
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
requiresVerification: true,
|
||||
challengeId: challenge.challengeId,
|
||||
challengeExpiresAt: challenge.expiresAt,
|
||||
sessionToken: null,
|
||||
sessionExpiresAt: null,
|
||||
},
|
||||
message: "Verification code sent",
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
|
||||
export class AdminLoginVerifyEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ["Admin API"],
|
||||
summary: "Verify admin login code",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: loginVerifyRequestSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(loginStartResponseSchema),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const parsedBody = loginVerifyRequestSchema.safeParse(
|
||||
await c.req.json<{ challengeId: string; code: string }>()
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return c.text(parsedBody.error.issues[0]?.message || "验证码格式无效", 400);
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const systemConfig = await getSystemConfig(c);
|
||||
const securityConfig = systemConfig.adminSecurity;
|
||||
|
||||
if (!isTelegramSecurityEnabled(securityConfig)) {
|
||||
return c.text("当前未开启 Telegram 登录验证", 400);
|
||||
}
|
||||
|
||||
const result = await verifyAdminLoginChallenge(
|
||||
c,
|
||||
body.challengeId,
|
||||
body.code
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
await sendAdminLoginResultNotification(
|
||||
securityConfig,
|
||||
c,
|
||||
"failure",
|
||||
result.reason
|
||||
).catch((error) => {
|
||||
console.error("Failed to send Telegram login failure notification:", error);
|
||||
});
|
||||
|
||||
return c.text(result.reason || "验证码验证失败", 401);
|
||||
}
|
||||
|
||||
const session = await createAdminSession(c);
|
||||
|
||||
await sendAdminLoginResultNotification(
|
||||
securityConfig,
|
||||
c,
|
||||
"success"
|
||||
).catch((error) => {
|
||||
console.error("Failed to send Telegram login success notification:", error);
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
requiresVerification: false,
|
||||
challengeId: null,
|
||||
challengeExpiresAt: null,
|
||||
sessionToken: session.sessionToken,
|
||||
sessionExpiresAt: session.expiresAt,
|
||||
},
|
||||
message: "Login verified successfully",
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
|
||||
export class AdminLogoutEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ["Admin API"],
|
||||
summary: "Logout admin session",
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(z.boolean()),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const sessionToken = c.req.header(getAdminSessionHeaderName());
|
||||
|
||||
await invalidateAdminSession(c, sessionToken);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: true,
|
||||
message: "Logout successful",
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import { Context } from "hono";
|
||||
|
||||
const ADMIN_SESSION_HEADER = "x-admin-session";
|
||||
const ADMIN_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const ADMIN_LOGIN_CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
const ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS = 5;
|
||||
const DEFAULT_SYSTEM_TIMEZONE = "Asia/Shanghai";
|
||||
|
||||
type LoginRequestMetadata = {
|
||||
clientIp: string;
|
||||
country: string;
|
||||
region: string;
|
||||
city: string;
|
||||
colo: string;
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
const firstNonEmpty = (...values: Array<string | null | undefined>): string => {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const formatTimestampInTimezone = (date: Date, timezone: string): string => {
|
||||
const formatter = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: timezone,
|
||||
hour12: false,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(date);
|
||||
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
|
||||
return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}:${values.second} (${timezone})`;
|
||||
};
|
||||
|
||||
const getRequestMetadata = (c: Context<HonoCustomType>): LoginRequestMetadata => {
|
||||
const requestCf = (c.req.raw.cf || {}) as Partial<IncomingRequestCfProperties<unknown>>;
|
||||
|
||||
return {
|
||||
clientIp: firstNonEmpty(
|
||||
c.req.header("cf-connecting-ip"),
|
||||
c.req.header("x-real-ip"),
|
||||
c.req.header("x-forwarded-for")?.split(",")[0]
|
||||
),
|
||||
country: firstNonEmpty(requestCf.country),
|
||||
region: firstNonEmpty(requestCf.region, requestCf.regionCode),
|
||||
city: firstNonEmpty(requestCf.city),
|
||||
colo: firstNonEmpty(requestCf.colo),
|
||||
timezone: firstNonEmpty(requestCf.timezone),
|
||||
};
|
||||
};
|
||||
|
||||
const getLocationText = (metadata: LoginRequestMetadata): string => {
|
||||
const location = [metadata.country, metadata.region, metadata.city]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
|
||||
return location || "未知位置";
|
||||
};
|
||||
|
||||
const createTelegramMessage = (
|
||||
lines: Array<string>
|
||||
): string => {
|
||||
return lines.filter(Boolean).join("\n");
|
||||
};
|
||||
|
||||
const generateNumericCode = (): string => {
|
||||
return String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0");
|
||||
};
|
||||
|
||||
const generateSessionToken = (): string => {
|
||||
return `${crypto.randomUUID().replace(/-/g, "")}${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
};
|
||||
|
||||
const toSha256Hex = async (value: string): Promise<string> => {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
};
|
||||
|
||||
const cleanupExpiredArtifacts = async (c: Context<HonoCustomType>) => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await Promise.all([
|
||||
c.env.DB.prepare(
|
||||
`DELETE FROM admin_login_challenge WHERE expires_at <= ?`
|
||||
).bind(now).run(),
|
||||
c.env.DB.prepare(
|
||||
`DELETE FROM admin_session WHERE expires_at <= ?`
|
||||
).bind(now).run(),
|
||||
]);
|
||||
};
|
||||
|
||||
const sendTelegramMessage = async (
|
||||
securityConfig: AdminSecurityConfig,
|
||||
text: string
|
||||
): Promise<void> => {
|
||||
const response = await fetch(
|
||||
`https://api.telegram.org/bot${securityConfig.telegramBotToken}/sendMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: securityConfig.telegramChatId,
|
||||
text,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const payload = await response.json()
|
||||
.catch(() => ({})) as { ok?: boolean; description?: string };
|
||||
|
||||
if (!response.ok || payload.ok !== true) {
|
||||
throw new Error(payload.description || `HTTP ${response.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
const buildBaseMessageLines = (
|
||||
metadata: LoginRequestMetadata,
|
||||
occurredAt: Date
|
||||
): Array<string> => {
|
||||
const clientTimezone = metadata.timezone || DEFAULT_SYSTEM_TIMEZONE;
|
||||
|
||||
return [
|
||||
`时间(客户端):${formatTimestampInTimezone(occurredAt, clientTimezone)}`,
|
||||
`时间(系统):${formatTimestampInTimezone(occurredAt, DEFAULT_SYSTEM_TIMEZONE)}`,
|
||||
`IP:${metadata.clientIp || "未知 IP"}`,
|
||||
`位置:${getLocationText(metadata)}`,
|
||||
`节点:${metadata.colo || "未知节点"}${metadata.timezone ? ` · ${metadata.timezone}` : ""}`,
|
||||
];
|
||||
};
|
||||
|
||||
export const getAdminSessionHeaderName = (): string => {
|
||||
return ADMIN_SESSION_HEADER;
|
||||
};
|
||||
|
||||
export const createAdminSession = async (
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<{ sessionToken: string; expiresAt: string }> => {
|
||||
await cleanupExpiredArtifacts(c);
|
||||
|
||||
const sessionToken = generateSessionToken();
|
||||
const tokenHash = await toSha256Hex(sessionToken);
|
||||
const expiresAt = new Date(Date.now() + ADMIN_SESSION_TTL_MS).toISOString();
|
||||
|
||||
await c.env.DB.prepare(
|
||||
`INSERT INTO admin_session (token_hash, expires_at)
|
||||
VALUES (?, ?)`
|
||||
).bind(tokenHash, expiresAt).run();
|
||||
|
||||
return {
|
||||
sessionToken,
|
||||
expiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const invalidateAdminSession = async (
|
||||
c: Context<HonoCustomType>,
|
||||
sessionToken: string | null | undefined
|
||||
): Promise<void> => {
|
||||
if (!sessionToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenHash = await toSha256Hex(sessionToken);
|
||||
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM admin_session WHERE token_hash = ?`
|
||||
).bind(tokenHash).run();
|
||||
};
|
||||
|
||||
export const validateAdminSession = async (
|
||||
c: Context<HonoCustomType>,
|
||||
sessionToken: string | null | undefined
|
||||
): Promise<boolean> => {
|
||||
if (!sessionToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await cleanupExpiredArtifacts(c);
|
||||
|
||||
const tokenHash = await toSha256Hex(sessionToken);
|
||||
const session = await c.env.DB.prepare(
|
||||
`SELECT token_hash, expires_at FROM admin_session WHERE token_hash = ?`
|
||||
).bind(tokenHash).first<Pick<AdminSessionRow, "token_hash" | "expires_at">>();
|
||||
|
||||
if (!session?.token_hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Date.parse(session.expires_at) <= Date.now()) {
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM admin_session WHERE token_hash = ?`
|
||||
).bind(tokenHash).run();
|
||||
return false;
|
||||
}
|
||||
|
||||
await c.env.DB.prepare(
|
||||
`UPDATE admin_session
|
||||
SET last_used_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE token_hash = ?`
|
||||
).bind(tokenHash).run();
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const createAdminLoginChallenge = async (
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<{ challengeId: string; code: string; expiresAt: string }> => {
|
||||
await cleanupExpiredArtifacts(c);
|
||||
|
||||
const challengeId = crypto.randomUUID();
|
||||
const code = generateNumericCode();
|
||||
const codeHash = await toSha256Hex(code);
|
||||
const expiresAt = new Date(Date.now() + ADMIN_LOGIN_CHALLENGE_TTL_MS).toISOString();
|
||||
const metadata = getRequestMetadata(c);
|
||||
|
||||
await c.env.DB.prepare(
|
||||
`INSERT INTO admin_login_challenge (
|
||||
id,
|
||||
code_hash,
|
||||
expires_at,
|
||||
attempts,
|
||||
max_attempts,
|
||||
request_ip,
|
||||
request_country,
|
||||
request_region,
|
||||
request_city,
|
||||
request_colo,
|
||||
request_timezone
|
||||
) VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).bind(
|
||||
challengeId,
|
||||
codeHash,
|
||||
expiresAt,
|
||||
ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS,
|
||||
metadata.clientIp,
|
||||
metadata.country,
|
||||
metadata.region,
|
||||
metadata.city,
|
||||
metadata.colo,
|
||||
metadata.timezone
|
||||
).run();
|
||||
|
||||
return {
|
||||
challengeId,
|
||||
code,
|
||||
expiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteAdminLoginChallenge = async (
|
||||
c: Context<HonoCustomType>,
|
||||
challengeId: string
|
||||
): Promise<void> => {
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM admin_login_challenge WHERE id = ?`
|
||||
).bind(challengeId).run();
|
||||
};
|
||||
|
||||
export const verifyAdminLoginChallenge = async (
|
||||
c: Context<HonoCustomType>,
|
||||
challengeId: string,
|
||||
code: string
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
}> => {
|
||||
await cleanupExpiredArtifacts(c);
|
||||
|
||||
const challenge = await c.env.DB.prepare(
|
||||
`SELECT * FROM admin_login_challenge WHERE id = ?`
|
||||
).bind(challengeId).first<AdminLoginChallengeRow>();
|
||||
|
||||
if (!challenge?.id) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "验证码已失效,请重新获取",
|
||||
};
|
||||
}
|
||||
|
||||
if (Date.parse(challenge.expires_at) <= Date.now()) {
|
||||
await deleteAdminLoginChallenge(c, challengeId);
|
||||
return {
|
||||
ok: false,
|
||||
reason: "验证码已过期,请重新获取",
|
||||
};
|
||||
}
|
||||
|
||||
if ((challenge.attempts || 0) >= (challenge.max_attempts || ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS)) {
|
||||
await deleteAdminLoginChallenge(c, challengeId);
|
||||
return {
|
||||
ok: false,
|
||||
reason: "验证码尝试次数过多,请重新获取",
|
||||
};
|
||||
}
|
||||
|
||||
const codeHash = await toSha256Hex(code);
|
||||
|
||||
if (codeHash !== challenge.code_hash) {
|
||||
const nextAttempts = (challenge.attempts || 0) + 1;
|
||||
|
||||
if (nextAttempts >= (challenge.max_attempts || ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS)) {
|
||||
await deleteAdminLoginChallenge(c, challengeId);
|
||||
return {
|
||||
ok: false,
|
||||
reason: "验证码错误,已达到最大尝试次数",
|
||||
};
|
||||
}
|
||||
|
||||
await c.env.DB.prepare(
|
||||
`UPDATE admin_login_challenge
|
||||
SET attempts = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).bind(nextAttempts, challengeId).run();
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: "验证码错误",
|
||||
};
|
||||
}
|
||||
|
||||
await deleteAdminLoginChallenge(c, challengeId);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const sendAdminLoginCodeNotification = async (
|
||||
securityConfig: AdminSecurityConfig,
|
||||
c: Context<HonoCustomType>,
|
||||
code: string,
|
||||
expiresAt: string
|
||||
): Promise<void> => {
|
||||
const metadata = getRequestMetadata(c);
|
||||
const occurredAt = new Date();
|
||||
const expiresDate = new Date(expiresAt);
|
||||
const clientTimezone = metadata.timezone || DEFAULT_SYSTEM_TIMEZONE;
|
||||
|
||||
await sendTelegramMessage(
|
||||
securityConfig,
|
||||
createTelegramMessage([
|
||||
"【One API 管理后台】",
|
||||
`登录验证码:${code}`,
|
||||
`过期时间:${formatTimestampInTimezone(expiresDate, clientTimezone)}`,
|
||||
...buildBaseMessageLines(metadata, occurredAt),
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
export const sendAdminLoginResultNotification = async (
|
||||
securityConfig: AdminSecurityConfig,
|
||||
c: Context<HonoCustomType>,
|
||||
status: "success" | "failure",
|
||||
reason?: string
|
||||
): Promise<void> => {
|
||||
const metadata = getRequestMetadata(c);
|
||||
const occurredAt = new Date();
|
||||
|
||||
await sendTelegramMessage(
|
||||
securityConfig,
|
||||
createTelegramMessage([
|
||||
"【One API 管理后台】",
|
||||
`登录${status === "success" ? "成功" : "失败"}`,
|
||||
reason ? `原因:${reason}` : "",
|
||||
...buildBaseMessageLines(metadata, occurredAt),
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
export const sendTelegramTestNotification = async (
|
||||
securityConfig: AdminSecurityConfig,
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<void> => {
|
||||
const metadata = getRequestMetadata(c);
|
||||
const occurredAt = new Date();
|
||||
|
||||
await sendTelegramMessage(
|
||||
securityConfig,
|
||||
createTelegramMessage([
|
||||
"【One API 管理后台】",
|
||||
"Telegram 绑定测试成功",
|
||||
...buildBaseMessageLines(metadata, occurredAt),
|
||||
])
|
||||
);
|
||||
};
|
||||
@@ -2,10 +2,9 @@ import { Context } from "hono";
|
||||
import { OpenAPIRoute } from "chanfana";
|
||||
import { z } from "zod";
|
||||
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
||||
import { BillingConfig, normalizeBillingConfig } from "../billing";
|
||||
import { getJsonSetting, saveSetting } from "../utils";
|
||||
import { getSystemConfig, saveSystemConfig } from "../system-config";
|
||||
|
||||
const billingConfigSchema = z.object({
|
||||
displayDecimals: z.number().int().min(0).max(9),
|
||||
@@ -22,14 +21,9 @@ export class BillingConfigGetEndpoint extends OpenAPIRoute {
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const billingConfig = await getJsonSetting<BillingConfig>(
|
||||
c,
|
||||
CONSTANTS.BILLING_CONFIG_KEY
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: normalizeBillingConfig(billingConfig),
|
||||
data: normalizeBillingConfig(await getSystemConfig(c)),
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
@@ -55,13 +49,13 @@ export class BillingConfigUpdateEndpoint extends OpenAPIRoute {
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const body = await c.req.json<BillingConfig>();
|
||||
const currentSystemConfig = await getSystemConfig(c);
|
||||
const config = normalizeBillingConfig(body);
|
||||
|
||||
await saveSetting(
|
||||
c,
|
||||
CONSTANTS.BILLING_CONFIG_KEY,
|
||||
JSON.stringify(config)
|
||||
);
|
||||
await saveSystemConfig(c, {
|
||||
...currentSystemConfig,
|
||||
displayDecimals: config.displayDecimals,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
+56
-10
@@ -14,6 +14,16 @@ import {
|
||||
import {
|
||||
BillingConfigGetEndpoint, BillingConfigUpdateEndpoint
|
||||
} from "./billing_api"
|
||||
import {
|
||||
SystemConfigGetEndpoint,
|
||||
SystemConfigUpdateEndpoint,
|
||||
TelegramTestMessageEndpoint,
|
||||
} from "./system_api"
|
||||
import {
|
||||
AdminLoginStartEndpoint,
|
||||
AdminLoginVerifyEndpoint,
|
||||
AdminLogoutEndpoint,
|
||||
} from "./auth_api"
|
||||
import {
|
||||
AnalyticsOverviewEndpoint,
|
||||
AnalyticsTrendEndpoint,
|
||||
@@ -21,28 +31,61 @@ import {
|
||||
AnalyticsEventsEndpoint,
|
||||
UsageLogSearchEndpoint,
|
||||
} from "./analytics_api"
|
||||
import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config"
|
||||
import { getAdminSessionHeaderName, validateAdminSession } from "./auth_shared"
|
||||
|
||||
const app = new Hono<HonoCustomType>()
|
||||
export const api = fromHono(app)
|
||||
|
||||
// Authentication Middleware - using environment variable
|
||||
app.use('/api/admin/*', async (c, next) => {
|
||||
const token = c.req.header('x-admin-token');
|
||||
const adminToken = c.env.ADMIN_TOKEN;
|
||||
|
||||
if (!token || !adminToken || token !== adminToken) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
const PUBLIC_AUTH_ROUTES = new Set([
|
||||
"/api/admin/auth/login",
|
||||
"/api/admin/auth/verify",
|
||||
]);
|
||||
|
||||
app.use('/api/admin/*', async (c, next) => {
|
||||
await db.ensureReady(c);
|
||||
await next();
|
||||
});
|
||||
|
||||
// Authentication Middleware - using environment variable or admin session
|
||||
app.use('/api/admin/*', async (c, next) => {
|
||||
if (PUBLIC_AUTH_ROUTES.has(c.req.path)) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionToken = c.req.header(getAdminSessionHeaderName());
|
||||
|
||||
if (await validateAdminSession(c, sessionToken)) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const systemConfig = await getSystemConfig(c);
|
||||
const securityEnabled = isTelegramSecurityEnabled(systemConfig.adminSecurity);
|
||||
const token = c.req.header('x-admin-token');
|
||||
const adminToken = c.env.ADMIN_TOKEN;
|
||||
|
||||
if (!securityEnabled && token && adminToken && token === adminToken) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
return c.text(
|
||||
securityEnabled
|
||||
? "Telegram 登录验证已开启,请先完成验证码登录"
|
||||
: "Unauthorized",
|
||||
401
|
||||
);
|
||||
});
|
||||
|
||||
api.post("/api/admin/db_initialize", DBInitializeEndpoint)
|
||||
|
||||
// Authentication routes
|
||||
api.post("/api/admin/auth/login", AdminLoginStartEndpoint)
|
||||
api.post("/api/admin/auth/verify", AdminLoginVerifyEndpoint)
|
||||
api.post("/api/admin/auth/logout", AdminLogoutEndpoint)
|
||||
|
||||
api.get("/api/admin/channel", ChannelGetEndpoint)
|
||||
api.post("/api/admin/channel/:key", ChannelUpsertEndpoint)
|
||||
api.delete("/api/admin/channel/:key", ChannelDeleteEndpoint)
|
||||
@@ -59,6 +102,9 @@ api.get("/api/admin/pricing", PricingGetEndpoint)
|
||||
api.post("/api/admin/pricing", PricingUpdateEndpoint)
|
||||
api.get("/api/admin/billing/config", BillingConfigGetEndpoint)
|
||||
api.post("/api/admin/billing/config", BillingConfigUpdateEndpoint)
|
||||
api.get("/api/admin/system/config", SystemConfigGetEndpoint)
|
||||
api.post("/api/admin/system/config", SystemConfigUpdateEndpoint)
|
||||
api.post("/api/admin/system/telegram/test", TelegramTestMessageEndpoint)
|
||||
|
||||
// Analytics management routes
|
||||
api.get("/api/admin/analytics/overview", AnalyticsOverviewEndpoint)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Context } from "hono";
|
||||
import { OpenAPIRoute } from "chanfana";
|
||||
import { z } from "zod";
|
||||
|
||||
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
||||
import {
|
||||
getSystemConfig,
|
||||
isTelegramSecurityEnabled,
|
||||
normalizeAdminSecurityConfig,
|
||||
normalizeSystemConfig,
|
||||
saveSystemConfig,
|
||||
} from "../system-config";
|
||||
import { sendTelegramTestNotification } from "./auth_shared";
|
||||
|
||||
const adminSecurityConfigSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
telegramBotToken: z.string(),
|
||||
telegramChatId: z.string(),
|
||||
});
|
||||
|
||||
const apiDocsConfigSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
const systemConfigSchema = z.object({
|
||||
displayDecimals: z.number().int().min(0).max(9),
|
||||
adminSecurity: adminSecurityConfigSchema,
|
||||
apiDocs: apiDocsConfigSchema,
|
||||
});
|
||||
|
||||
const telegramTestRequestSchema = z.object({
|
||||
telegramBotToken: z.string().trim().min(1, "Bot Token 不能为空"),
|
||||
telegramChatId: z.string().trim().min(1, "Chat ID 不能为空"),
|
||||
});
|
||||
|
||||
const ensureSystemConfigValid = (config: SystemConfig) => {
|
||||
if (
|
||||
config.adminSecurity.enabled
|
||||
&& !isTelegramSecurityEnabled(config.adminSecurity)
|
||||
) {
|
||||
throw new Error("开启 Telegram 验证前,请先填写 Bot Token 和 Chat ID");
|
||||
}
|
||||
};
|
||||
|
||||
export class SystemConfigGetEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ["Admin API"],
|
||||
summary: "Get system configuration",
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(systemConfigSchema),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
return {
|
||||
success: true,
|
||||
data: await getSystemConfig(c),
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
|
||||
export class SystemConfigUpdateEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ["Admin API"],
|
||||
summary: "Update system configuration",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: systemConfigSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(systemConfigSchema),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const body = await c.req.json<SystemConfig>();
|
||||
const config = normalizeSystemConfig(body);
|
||||
|
||||
try {
|
||||
ensureSystemConfigValid(config);
|
||||
} catch (error) {
|
||||
return c.text(
|
||||
error instanceof Error ? error.message : "系统设置无效",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: await saveSystemConfig(c, config),
|
||||
message: "System config updated successfully",
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
|
||||
export class TelegramTestMessageEndpoint extends OpenAPIRoute {
|
||||
schema = {
|
||||
tags: ["Admin API"],
|
||||
summary: "Send Telegram test notification",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: telegramTestRequestSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...CommonSuccessfulResponse(z.boolean()),
|
||||
...CommonErrorResponse,
|
||||
},
|
||||
};
|
||||
|
||||
async handle(c: Context<HonoCustomType>) {
|
||||
const parsedBody = telegramTestRequestSchema.safeParse(
|
||||
await c.req.json<Pick<AdminSecurityConfig, "telegramBotToken" | "telegramChatId">>()
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return c.text(parsedBody.error.issues[0]?.message || "Telegram 配置无效", 400);
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const securityConfig = {
|
||||
...normalizeAdminSecurityConfig(body),
|
||||
enabled: true,
|
||||
} satisfies AdminSecurityConfig;
|
||||
|
||||
if (!isTelegramSecurityEnabled(securityConfig)) {
|
||||
return c.text("请先填写有效的 Bot Token 和 Chat ID", 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await sendTelegramTestNotification(securityConfig, c);
|
||||
} catch (error) {
|
||||
return c.text(
|
||||
`Telegram 测试消息发送失败:${error instanceof Error ? error.message : "unknown error"}`,
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: true,
|
||||
message: "Telegram test message sent successfully",
|
||||
} as CommonResponse;
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,5 @@ export const CONSTANTS = {
|
||||
DB_VERSION: "v0.0.6",
|
||||
MODEL_PRICING_KEY: "MODEL_PRICING_CONFIG",
|
||||
BILLING_CONFIG_KEY: "BILLING_CONFIG",
|
||||
SYSTEM_CONFIG_KEY: "SYSTEM_CONFIG",
|
||||
}
|
||||
@@ -24,6 +24,32 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admin_login_challenge (
|
||||
id TEXT PRIMARY KEY,
|
||||
code_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
max_attempts INTEGER DEFAULT 5,
|
||||
request_ip TEXT DEFAULT '',
|
||||
request_country TEXT DEFAULT '',
|
||||
request_region TEXT DEFAULT '',
|
||||
request_city TEXT DEFAULT '',
|
||||
request_colo TEXT DEFAULT '',
|
||||
request_timezone TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admin_session (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_login_challenge_expires_at
|
||||
ON admin_login_challenge (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_session_expires_at
|
||||
ON admin_session (expires_at);
|
||||
`
|
||||
|
||||
let dbReadyPromise: Promise<void> | null = null;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { cors } from 'hono/cors';
|
||||
import { api as providerApi } from './providers'
|
||||
import { api as adminApi } from './admin'
|
||||
import { fromHono } from 'chanfana';
|
||||
import db from './db';
|
||||
import { getSystemConfig } from './system-config';
|
||||
|
||||
const FRONTEND_ENTRY = '/'
|
||||
const FRONTEND_STATIC_PATHS = new Set([
|
||||
@@ -22,6 +24,11 @@ const FRONTEND_STATIC_PREFIXES = [
|
||||
'/src/',
|
||||
]
|
||||
const LOCAL_DEV_HOSTNAMES = new Set(['0.0.0.0', '127.0.0.1', '::1', 'localhost'])
|
||||
const API_DOC_ROUTE_PATHS = new Set([
|
||||
'/api/docs',
|
||||
'/api/redocs',
|
||||
'/api/openapi.json',
|
||||
])
|
||||
type AppContext = Context<HonoCustomType>
|
||||
|
||||
function isApiRequest(pathname: string): boolean {
|
||||
@@ -96,6 +103,15 @@ openapi.use('/*', cors());
|
||||
app.use('*', async (c, next) => {
|
||||
const requestUrl = new URL(c.req.url)
|
||||
|
||||
if (API_DOC_ROUTE_PATHS.has(requestUrl.pathname)) {
|
||||
await db.ensureReady(c);
|
||||
|
||||
const systemConfig = await getSystemConfig(c);
|
||||
if (!systemConfig.apiDocs.enabled) {
|
||||
return c.notFound();
|
||||
}
|
||||
}
|
||||
|
||||
if (isApiRequest(requestUrl.pathname)) {
|
||||
await next()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Context } from "hono";
|
||||
|
||||
import {
|
||||
DEFAULT_BILLING_DISPLAY_DECIMALS,
|
||||
normalizeBillingDisplayDecimals,
|
||||
} from "./billing";
|
||||
import { CONSTANTS } from "./constants";
|
||||
import { getJsonSetting, saveSetting } from "./utils";
|
||||
|
||||
export const DEFAULT_ADMIN_SECURITY_CONFIG: AdminSecurityConfig = {
|
||||
enabled: false,
|
||||
telegramBotToken: "",
|
||||
telegramChatId: "",
|
||||
};
|
||||
|
||||
export const DEFAULT_API_DOCS_CONFIG: ApiDocsConfig = {
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_SYSTEM_CONFIG: SystemConfig = {
|
||||
displayDecimals: DEFAULT_BILLING_DISPLAY_DECIMALS,
|
||||
adminSecurity: DEFAULT_ADMIN_SECURITY_CONFIG,
|
||||
apiDocs: DEFAULT_API_DOCS_CONFIG,
|
||||
};
|
||||
|
||||
const normalizeBoolean = (value: unknown, fallback = false): boolean => {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
};
|
||||
|
||||
const normalizeString = (value: unknown): string => {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
};
|
||||
|
||||
export const normalizeAdminSecurityConfig = (
|
||||
value: Partial<AdminSecurityConfig> | null | undefined
|
||||
): AdminSecurityConfig => {
|
||||
return {
|
||||
enabled: normalizeBoolean(value?.enabled),
|
||||
telegramBotToken: normalizeString(value?.telegramBotToken),
|
||||
telegramChatId: normalizeString(value?.telegramChatId),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeApiDocsConfig = (
|
||||
value: Partial<ApiDocsConfig> | null | undefined
|
||||
): ApiDocsConfig => {
|
||||
return {
|
||||
enabled: normalizeBoolean(value?.enabled, DEFAULT_API_DOCS_CONFIG.enabled),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeSystemConfig = (
|
||||
value: Partial<SystemConfig> | null | undefined
|
||||
): SystemConfig => {
|
||||
return {
|
||||
displayDecimals: normalizeBillingDisplayDecimals(value?.displayDecimals),
|
||||
adminSecurity: normalizeAdminSecurityConfig(value?.adminSecurity),
|
||||
apiDocs: normalizeApiDocsConfig(value?.apiDocs),
|
||||
};
|
||||
};
|
||||
|
||||
export const isTelegramSecurityEnabled = (
|
||||
value: AdminSecurityConfig | SystemConfig | null | undefined
|
||||
): boolean => {
|
||||
const security = value && "adminSecurity" in value
|
||||
? value.adminSecurity
|
||||
: value;
|
||||
|
||||
if (!security) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return security.enabled
|
||||
&& security.telegramBotToken.length > 0
|
||||
&& security.telegramChatId.length > 0;
|
||||
};
|
||||
|
||||
export const getSystemConfig = async (
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<SystemConfig> => {
|
||||
const systemConfig = await getJsonSetting<SystemConfig>(
|
||||
c,
|
||||
CONSTANTS.SYSTEM_CONFIG_KEY
|
||||
);
|
||||
|
||||
if (systemConfig) {
|
||||
return normalizeSystemConfig(systemConfig);
|
||||
}
|
||||
|
||||
const legacyBillingConfig = await getJsonSetting<BillingConfig>(
|
||||
c,
|
||||
CONSTANTS.BILLING_CONFIG_KEY
|
||||
);
|
||||
|
||||
return normalizeSystemConfig({
|
||||
displayDecimals: legacyBillingConfig?.displayDecimals,
|
||||
});
|
||||
};
|
||||
|
||||
export const saveSystemConfig = async (
|
||||
c: Context<HonoCustomType>,
|
||||
value: Partial<SystemConfig> | null | undefined
|
||||
): Promise<SystemConfig> => {
|
||||
const config = normalizeSystemConfig(value);
|
||||
|
||||
await saveSetting(
|
||||
c,
|
||||
CONSTANTS.SYSTEM_CONFIG_KEY,
|
||||
JSON.stringify(config)
|
||||
);
|
||||
|
||||
await saveSetting(
|
||||
c,
|
||||
CONSTANTS.BILLING_CONFIG_KEY,
|
||||
JSON.stringify({
|
||||
displayDecimals: config.displayDecimals,
|
||||
} satisfies BillingConfig)
|
||||
);
|
||||
|
||||
return config;
|
||||
};
|
||||
Reference in new issue
Block a user