feat(auth): 根据安全级别调整管理员会话有效期
This commit is contained in:
1 parent
3781bd9061
commit
7b3a916c86
4 files changed
+105
-18
No files matched your search
@@ -12,6 +12,7 @@
|
||||
当前实现的认证与安全要点:
|
||||
|
||||
- 管理后台使用 session cookie,而不是每次请求直接传管理员令牌
|
||||
- session 有效期按登录安全级别区分:未启用 Telegram 验证时为 7 天,启用后为 30 天
|
||||
- 登录、验证码验证和 Telegram 通知都带应用层限速
|
||||
- API 文档可在系统设置中关闭;关闭后 `/api/docs`、`/api/redocs`、`/api/openapi.json` 会直接返回 `404`
|
||||
|
||||
|
||||
@@ -77,10 +77,11 @@ const loginVerifyRequestSchema = z.object({
|
||||
});
|
||||
|
||||
const buildDirectLoginResponse = async (
|
||||
c: Context<HonoCustomType>
|
||||
c: Context<HonoCustomType>,
|
||||
telegramSecurityEnabled: boolean
|
||||
) => {
|
||||
const session = await createAdminSession(c);
|
||||
setAdminSessionCookie(c, session.sessionToken, session.expiresAt);
|
||||
const session = await createAdminSession(c, telegramSecurityEnabled);
|
||||
setAdminSessionCookie(c, session.sessionToken, session.expiresAt, session.ttlMs);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -178,7 +179,7 @@ export class AdminLoginStartEndpoint extends OpenAPIRoute {
|
||||
);
|
||||
|
||||
if (!securityEnabled) {
|
||||
return buildDirectLoginResponse(c);
|
||||
return buildDirectLoginResponse(c, securityEnabled);
|
||||
}
|
||||
|
||||
const challenge = await createAdminLoginChallenge(c);
|
||||
@@ -266,8 +267,9 @@ export class AdminLoginVerifyEndpoint extends OpenAPIRoute {
|
||||
|
||||
const systemConfig = await getSystemConfig(c);
|
||||
const securityConfig = systemConfig.adminSecurity;
|
||||
const securityEnabled = isTelegramSecurityEnabled(securityConfig);
|
||||
|
||||
if (!isTelegramSecurityEnabled(securityConfig)) {
|
||||
if (!securityEnabled) {
|
||||
return c.text("当前未开启 Telegram 登录验证", 400);
|
||||
}
|
||||
|
||||
@@ -316,8 +318,8 @@ export class AdminLoginVerifyEndpoint extends OpenAPIRoute {
|
||||
),
|
||||
]);
|
||||
|
||||
const session = await createAdminSession(c);
|
||||
setAdminSessionCookie(c, session.sessionToken, session.expiresAt);
|
||||
const session = await createAdminSession(c, securityEnabled);
|
||||
setAdminSessionCookie(c, session.sessionToken, session.expiresAt, session.ttlMs);
|
||||
|
||||
await sendAdminLoginResultNotification(
|
||||
securityConfig,
|
||||
|
||||
+31
-11
@@ -2,7 +2,8 @@ import { Context } from "hono";
|
||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||
|
||||
const ADMIN_SESSION_COOKIE_NAME = "oaw_admin_session";
|
||||
const ADMIN_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const ADMIN_SESSION_TTL_WITHOUT_TELEGRAM_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const ADMIN_SESSION_TTL_WITH_TELEGRAM_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const ADMIN_LOGIN_CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
const ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS = 5;
|
||||
const ADMIN_RATE_LIMIT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -383,16 +384,31 @@ const isSecureCookieRequest = (c: Context<HonoCustomType>): boolean => {
|
||||
return requestUrl.protocol === "https:" && !LOCAL_DEV_HOSTNAMES.has(requestUrl.hostname);
|
||||
};
|
||||
|
||||
const buildAdminSessionCookieOptions = (
|
||||
c: Context<HonoCustomType>,
|
||||
expiresAt?: string
|
||||
const getAdminSessionTtlMs = (telegramSecurityEnabled: boolean): number => {
|
||||
return telegramSecurityEnabled
|
||||
? ADMIN_SESSION_TTL_WITH_TELEGRAM_MS
|
||||
: ADMIN_SESSION_TTL_WITHOUT_TELEGRAM_MS;
|
||||
};
|
||||
|
||||
const buildAdminSessionCookieBaseOptions = (
|
||||
c: Context<HonoCustomType>
|
||||
) => {
|
||||
return {
|
||||
httpOnly: true,
|
||||
maxAge: Math.floor(ADMIN_SESSION_TTL_MS / 1000),
|
||||
path: "/api/admin",
|
||||
sameSite: "Lax" as const,
|
||||
secure: isSecureCookieRequest(c),
|
||||
};
|
||||
};
|
||||
|
||||
const buildAdminSessionCookieOptions = (
|
||||
c: Context<HonoCustomType>,
|
||||
sessionTtlMs: number,
|
||||
expiresAt?: string
|
||||
) => {
|
||||
return {
|
||||
...buildAdminSessionCookieBaseOptions(c),
|
||||
maxAge: Math.floor(sessionTtlMs / 1000),
|
||||
...(expiresAt ? { expires: new Date(expiresAt) } : {}),
|
||||
};
|
||||
};
|
||||
@@ -406,13 +422,14 @@ export const getAdminSessionTokenFromRequest = (
|
||||
export const setAdminSessionCookie = (
|
||||
c: Context<HonoCustomType>,
|
||||
sessionToken: string,
|
||||
expiresAt: string
|
||||
expiresAt: string,
|
||||
sessionTtlMs: number
|
||||
): void => {
|
||||
setCookie(
|
||||
c,
|
||||
ADMIN_SESSION_COOKIE_NAME,
|
||||
sessionToken,
|
||||
buildAdminSessionCookieOptions(c, expiresAt)
|
||||
buildAdminSessionCookieOptions(c, sessionTtlMs, expiresAt)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -422,18 +439,20 @@ export const clearAdminSessionCookie = (
|
||||
deleteCookie(
|
||||
c,
|
||||
ADMIN_SESSION_COOKIE_NAME,
|
||||
buildAdminSessionCookieOptions(c)
|
||||
buildAdminSessionCookieBaseOptions(c)
|
||||
);
|
||||
};
|
||||
|
||||
export const createAdminSession = async (
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<{ sessionToken: string; expiresAt: string }> => {
|
||||
c: Context<HonoCustomType>,
|
||||
telegramSecurityEnabled = false
|
||||
): Promise<{ sessionToken: string; expiresAt: string; ttlMs: number }> => {
|
||||
await cleanupExpiredArtifacts(c);
|
||||
|
||||
const ttlMs = getAdminSessionTtlMs(telegramSecurityEnabled);
|
||||
const sessionToken = generateSessionToken();
|
||||
const tokenHash = await toSha256Hex(sessionToken);
|
||||
const expiresAt = new Date(Date.now() + ADMIN_SESSION_TTL_MS).toISOString();
|
||||
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
|
||||
|
||||
await c.env.DB.prepare(
|
||||
`INSERT INTO admin_session (token_hash, expires_at)
|
||||
@@ -443,6 +462,7 @@ export const createAdminSession = async (
|
||||
return {
|
||||
sessionToken,
|
||||
expiresAt,
|
||||
ttlMs,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -55,10 +55,31 @@ assert_http() {
|
||||
fi
|
||||
}
|
||||
|
||||
assert_between() {
|
||||
local name="$1" actual="$2" min="$3" max="$4"
|
||||
TOTAL=$((TOTAL + 1))
|
||||
if [[ "$actual" =~ ^-?[0-9]+$ ]] && [[ "$actual" -ge "$min" ]] && [[ "$actual" -le "$max" ]]; then
|
||||
echo " ✅ #$TOTAL $name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ #$TOTAL $name"
|
||||
echo " expected: [$min, $max]"
|
||||
echo " actual: ${actual:-<empty>}"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
do_get() { curl -s -w "\n%{http_code}" "$@"; }
|
||||
do_post() { curl -s -w "\n%{http_code}" -X POST "$@"; }
|
||||
reset_mock_inspection() { curl -s -X POST "$MOCK_API/__reset" > /dev/null; }
|
||||
get_mock_inspection() { curl -sG "$MOCK_API/__inspect" --data-urlencode "key=$1"; }
|
||||
json_field() {
|
||||
local path="$1"
|
||||
node -e "let raw=''; const path = process.argv[1].split('.'); process.stdin.on('data', (chunk) => raw += chunk); process.stdin.on('end', () => { const payload = JSON.parse(raw); let current = payload; for (const segment of path) { current = current?.[segment]; } process.stdout.write(current == null ? '' : String(current)); });" "$path"
|
||||
}
|
||||
ttl_seconds_until_iso() {
|
||||
node -e "const expiresAt = Date.parse(process.argv[1]); process.stdout.write(Number.isFinite(expiresAt) ? String(Math.round((expiresAt - Date.now()) / 1000)) : '');" "$1"
|
||||
}
|
||||
set_system_config_disabled() {
|
||||
"${DB_EXEC[@]}" "INSERT OR REPLACE INTO settings (key, value) VALUES ('SYSTEM_CONFIG', '{\"displayDecimals\":6,\"adminSecurity\":{\"enabled\":false,\"telegramBotToken\":\"\",\"telegramChatId\":\"\",\"verifiedFingerprint\":\"\",\"verifiedAt\":null},\"apiDocs\":{\"enabled\":true}}')" > /dev/null 2>&1
|
||||
}
|
||||
@@ -67,6 +88,12 @@ set_system_config_with_telegram() {
|
||||
fingerprint=$(node -e "const source = process.argv[1] + '::' + process.argv[2]; let hash = 2166136261; for (let index = 0; index < source.length; index += 1) { hash ^= source.charCodeAt(index); hash = Math.imul(hash, 16777619); } process.stdout.write(source ? 'tgv1-' + ((hash >>> 0).toString(16)) : '');" "$bot_token" "$chat_id")
|
||||
"${DB_EXEC[@]}" "INSERT OR REPLACE INTO settings (key, value) VALUES ('SYSTEM_CONFIG', '{\"displayDecimals\":6,\"adminSecurity\":{\"enabled\":true,\"telegramBotToken\":\"$bot_token\",\"telegramChatId\":\"$chat_id\",\"verifiedFingerprint\":\"$fingerprint\",\"verifiedAt\":\"2026-04-14T00:00:00.000Z\"},\"apiDocs\":{\"enabled\":true}}')" > /dev/null 2>&1
|
||||
}
|
||||
seed_login_challenge() {
|
||||
local challenge_id="$1" code="$2" request_ip="$3" code_hash expires_at
|
||||
code_hash=$(node -e "const crypto = require('node:crypto'); process.stdout.write(crypto.createHash('sha256').update(process.argv[1]).digest('hex'))" "$code")
|
||||
expires_at=$(date -u -d "+5 minutes" +"%Y-%m-%dT%H:%M:%SZ")
|
||||
"${DB_EXEC[@]}" "INSERT OR REPLACE 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 ('$challenge_id', '$code_hash', '$expires_at', 0, 5, '$request_ip', '', '', '', '', 'Asia/Shanghai')" > /dev/null 2>&1
|
||||
}
|
||||
seed_blocked_rate_limit() {
|
||||
local category="$1" bucket_id="$2" attempts="$3" blocked_until="$4" now_iso
|
||||
now_iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
@@ -263,6 +290,13 @@ R=$(do_post "$API/api/admin/auth/login" -H "Content-Type: application/json" -c "
|
||||
assert_http "admin login → 200" "200" "\"requiresVerification\":false" "$R"
|
||||
assert "admin login response keeps session token out of body" "\"sessionToken\":null" "$R"
|
||||
assert "admin login sets session cookie" "oaw_admin_session" "$(cat "$COOKIE_JAR")"
|
||||
LOGIN_BODY=$(echo "$R" | sed '$d')
|
||||
LOGIN_SESSION_EXPIRES_AT=$(printf '%s' "$LOGIN_BODY" | json_field "data.sessionExpiresAt")
|
||||
LOGIN_SESSION_TTL_SECONDS=$(ttl_seconds_until_iso "$LOGIN_SESSION_EXPIRES_AT")
|
||||
assert_between "admin session ttl without Telegram → about 7 days" "$LOGIN_SESSION_TTL_SECONDS" 604200 605400
|
||||
LOGIN_COOKIE_EXPIRY=$(awk 'NF >= 7 && $6 == "oaw_admin_session" {print $5}' "$COOKIE_JAR")
|
||||
LOGIN_COOKIE_TTL_SECONDS=$((LOGIN_COOKIE_EXPIRY - $(date +%s)))
|
||||
assert_between "admin session cookie ttl without Telegram → about 7 days" "$LOGIN_COOKIE_TTL_SECONDS" 604200 605400
|
||||
|
||||
R=$(do_get -b "$COOKIE_JAR" "$API/api/admin/channel")
|
||||
assert_http "admin cookie grants access" "200" "\"success\":true" "$R"
|
||||
@@ -275,6 +309,36 @@ assert_http "admin cookie invalid after logout" "401" "Unauthorized" "$R"
|
||||
|
||||
rm -f "$COOKIE_JAR"
|
||||
|
||||
# ---- Admin cookie session with Telegram ----
|
||||
echo ""
|
||||
echo "--- Admin session cookie with Telegram ---"
|
||||
|
||||
set_system_config_with_telegram "dummy-bot-token" "123456"
|
||||
COOKIE_JAR=$(mktemp)
|
||||
VERIFY_IP="203.0.113.72"
|
||||
VERIFY_CHALLENGE_ID="challenge-e2e-telegram"
|
||||
VERIFY_CODE="123456"
|
||||
seed_login_challenge "$VERIFY_CHALLENGE_ID" "$VERIFY_CODE" "$VERIFY_IP"
|
||||
|
||||
R=$(do_post "$API/api/admin/auth/verify" -H "Content-Type: application/json" -H "CF-Connecting-IP: $VERIFY_IP" -c "$COOKIE_JAR" -d "{\"challengeId\":\"$VERIFY_CHALLENGE_ID\",\"code\":\"$VERIFY_CODE\"}")
|
||||
assert_http "admin verify login → 200" "200" "\"requiresVerification\":false" "$R"
|
||||
assert "admin verify sets session cookie" "oaw_admin_session" "$(cat "$COOKIE_JAR")"
|
||||
VERIFY_BODY=$(echo "$R" | sed '$d')
|
||||
VERIFY_SESSION_EXPIRES_AT=$(printf '%s' "$VERIFY_BODY" | json_field "data.sessionExpiresAt")
|
||||
VERIFY_SESSION_TTL_SECONDS=$(ttl_seconds_until_iso "$VERIFY_SESSION_EXPIRES_AT")
|
||||
assert_between "admin session ttl with Telegram → about 30 days" "$VERIFY_SESSION_TTL_SECONDS" 2591400 2592600
|
||||
VERIFY_COOKIE_EXPIRY=$(awk 'NF >= 7 && $6 == "oaw_admin_session" {print $5}' "$COOKIE_JAR")
|
||||
VERIFY_COOKIE_TTL_SECONDS=$((VERIFY_COOKIE_EXPIRY - $(date +%s)))
|
||||
assert_between "admin session cookie ttl with Telegram → about 30 days" "$VERIFY_COOKIE_TTL_SECONDS" 2591400 2592600
|
||||
|
||||
R=$(do_get -b "$COOKIE_JAR" "$API/api/admin/channel")
|
||||
assert_http "admin Telegram session cookie grants access" "200" "\"success\":true" "$R"
|
||||
|
||||
R=$(do_post -b "$COOKIE_JAR" -c "$COOKIE_JAR" "$API/api/admin/auth/logout")
|
||||
assert_http "admin Telegram session logout → 200" "200" "\"success\":true" "$R"
|
||||
|
||||
rm -f "$COOKIE_JAR"
|
||||
|
||||
# ---- Admin anti-bruteforce ----
|
||||
echo ""
|
||||
echo "--- Admin anti-bruteforce ---"
|
||||
|
||||
Reference in new issue
Block a user