release: v1.2.7 — 登录人机验证(滑块 / Turnstile / Cap)
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s

This commit is contained in:
O96u committed 2026-09-03 13:53:43 +08:00
1 parent 8408d01197
commit 430c4de0f4
42 files changed
+1103 -72

No files matched your search

+4 -1
View File
@@ -180,7 +180,10 @@ export async function getImageUserFilter(
}
const user = await requireUserAuth(event)
return user.role === 'admin' ? 'admin' : user.id
if (user.role === 'admin') {
return 'admin'
}
return user.id
}
export async function assertImageOwnership(
+5
View File
@@ -384,6 +384,11 @@ export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at'
export const SETTINGS_ALLOW_REGISTRATION = 'allow_registration'
export const SETTINGS_LOGIN_VERIFICATION_METHOD = 'login_verification_method'
export const SETTINGS_TURNSTILE_SITE_KEY = 'turnstile_site_key'
export const SETTINGS_TURNSTILE_SECRET_KEY = 'turnstile_secret_key'
export const SETTINGS_CAP_API_ENDPOINT = 'cap_api_endpoint'
export const SETTINGS_CAP_SECRET = 'cap_secret'
export function isAllowRegistration(): boolean {
return getSetting(SETTINGS_ALLOW_REGISTRATION) === 'true'
+97
View File
@@ -11,6 +11,11 @@ import {
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY,
SETTINGS_LOGIN_VERIFICATION_METHOD,
SETTINGS_TURNSTILE_SITE_KEY,
SETTINGS_TURNSTILE_SECRET_KEY,
SETTINGS_CAP_API_ENDPOINT,
SETTINGS_CAP_SECRET,
isAllowRegistration
} from './db'
@@ -257,6 +262,93 @@ export function getApiUploadToken(event: H3Event): string {
return ''
}
export const LOGIN_VERIFICATION_METHODS = ['slider', 'turnstile', 'cap'] as const
export type LoginVerificationMethod = typeof LOGIN_VERIFICATION_METHODS[number]
const DEFAULT_LOGIN_VERIFICATION_METHOD: LoginVerificationMethod = 'slider'
export function parseLoginVerificationMethod(raw: string): LoginVerificationMethod | null {
if (raw === 'slider' || raw === 'turnstile' || raw === 'cap') return raw
return null
}
export function getLoginVerificationMethod(_event?: H3Event): LoginVerificationMethod {
const fromDb = getSetting(SETTINGS_LOGIN_VERIFICATION_METHOD)
if (fromDb !== null) {
return parseLoginVerificationMethod(fromDb) ?? DEFAULT_LOGIN_VERIFICATION_METHOD
}
return DEFAULT_LOGIN_VERIFICATION_METHOD
}
export function getTurnstileSiteKeyConfigured(_event?: H3Event): string {
return getSetting(SETTINGS_TURNSTILE_SITE_KEY)?.trim() ?? ''
}
export function getTurnstileSecretKey(_event?: H3Event): string {
return getSetting(SETTINGS_TURNSTILE_SECRET_KEY)?.trim() ?? ''
}
export function normalizeCapApiEndpoint(raw: string): string {
const trimmed = raw.trim()
if (!trimmed) return ''
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`
}
export function getCapApiEndpointConfigured(_event?: H3Event): string {
const fromDb = getSetting(SETTINGS_CAP_API_ENDPOINT)
return fromDb ? normalizeCapApiEndpoint(fromDb) : ''
}
export function getCapSecret(_event?: H3Event): string {
return getSetting(SETTINGS_CAP_SECRET)?.trim() ?? ''
}
export interface LoginVerificationPublicConfig {
method: LoginVerificationMethod
turnstileSiteKey?: string
capApiEndpoint?: string
}
export function getLoginVerificationPublicConfig(event: H3Event): LoginVerificationPublicConfig {
const method = getLoginVerificationMethod(event)
const config: LoginVerificationPublicConfig = { method }
if (method === 'turnstile') {
const siteKey = getTurnstileSiteKeyConfigured(event)
if (siteKey) config.turnstileSiteKey = siteKey
}
if (method === 'cap') {
const endpoint = getCapApiEndpointConfigured(event)
if (endpoint) config.capApiEndpoint = endpoint
}
return config
}
export function validateLoginVerificationSettings(
method: LoginVerificationMethod,
turnstileSiteKey: string,
turnstileSecretKey: string,
capApiEndpoint: string,
capSecret: string
): string | null {
if (method === 'turnstile') {
if (!turnstileSiteKey.trim()) {
return '请配置 Turnstile Site Key'
}
if (!turnstileSecretKey.trim()) {
return '请配置 Turnstile Secret Key'
}
}
if (method === 'cap') {
if (!normalizeCapApiEndpoint(capApiEndpoint)) {
return '请配置 Cap API Endpoint'
}
if (!capSecret.trim()) {
return '请配置 Cap Secret Key'
}
}
return null
}
export const DEFAULT_WEBP_QUALITY = 80
export type WebpQualitySource = 'env' | 'db' | 'default'
@@ -596,6 +688,11 @@ export function getSettingsPayload(event: H3Event) {
autoDeleteDaysSource: getAutoDeleteDaysSource(),
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
allowRegistration: isAllowRegistration(),
loginVerificationMethod: getLoginVerificationMethod(event),
turnstileSiteKey: getTurnstileSiteKeyConfigured(event),
turnstileSecretKey: getTurnstileSecretKey(event),
capApiEndpoint: getCapApiEndpointConfigured(event),
capSecret: getCapSecret(event),
appVersion,
env: {
webpQuality,
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { verifyTurnstileToken } from './verify-turnstile'
import { verifyCapToken } from './verify-cap'
describe('verify-turnstile', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns true when siteverify succeeds', async () => {
vi.mocked(fetch).mockResolvedValue({
json: async () => ({ success: true })
} as Response)
await expect(verifyTurnstileToken('secret', 'token', '1.2.3.4')).resolves.toBe(true)
})
it('returns false when siteverify fails', async () => {
vi.mocked(fetch).mockResolvedValue({
json: async () => ({ success: false })
} as Response)
await expect(verifyTurnstileToken('secret', 'token')).resolves.toBe(false)
})
it('returns false when token is missing', async () => {
await expect(verifyTurnstileToken('secret', '')).resolves.toBe(false)
expect(fetch).not.toHaveBeenCalled()
})
})
describe('verify-cap', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('posts to endpoint siteverify and returns true on success', async () => {
vi.mocked(fetch).mockResolvedValue({
json: async () => ({ success: true })
} as Response)
const ok = await verifyCapToken(
'https://cap.example.com/site-key/',
'secret',
'token'
)
expect(ok).toBe(true)
expect(fetch).toHaveBeenCalledWith(
'https://cap.example.com/site-key/siteverify',
expect.objectContaining({ method: 'POST' })
)
})
it('returns false on network error', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('network'))
await expect(verifyCapToken(
'https://cap.example.com/site-key/',
'secret',
'token'
)).resolves.toBe(false)
})
})
+65
View File
@@ -0,0 +1,65 @@
import type { H3Event } from 'h3'
import { createApiError } from './api-error'
import {
getCapApiEndpointConfigured,
getCapSecret,
getLoginVerificationMethod,
getTurnstileSecretKey,
getTurnstileSiteKeyConfigured
} from './env'
import { verifyLoginCaptcha } from './login-captcha'
import { clientIp } from './logger'
import { verifyCapToken } from './verify-cap'
import { verifyTurnstileToken } from './verify-turnstile'
export interface VerificationBody {
captchaId?: string
captchaPosition?: number
turnstileToken?: string
capToken?: string
}
export async function verifyLoginVerification(
event: H3Event,
body: VerificationBody
): Promise<void> {
const method = getLoginVerificationMethod(event)
if (method === 'slider') {
const id = body.captchaId?.trim() ?? ''
const position = Number(body.captchaPosition)
if (!id || !verifyLoginCaptcha(id, position)) {
createApiError(event, 'INVALID_REQUEST', '请先完成滑块验证', 400)
}
return
}
if (method === 'turnstile') {
if (!getTurnstileSiteKeyConfigured(event) || !getTurnstileSecretKey(event)) {
createApiError(event, 'INVALID_REQUEST', '人机验证未正确配置', 400)
}
const token = body.turnstileToken?.trim() ?? ''
const ok = await verifyTurnstileToken(
getTurnstileSecretKey(event),
token,
clientIp(event)
)
if (!ok) {
createApiError(event, 'INVALID_REQUEST', '请先完成人机验证', 400)
}
return
}
if (!getCapApiEndpointConfigured(event) || !getCapSecret(event)) {
createApiError(event, 'INVALID_REQUEST', '人机验证未正确配置', 400)
}
const token = body.capToken?.trim() ?? ''
const ok = await verifyCapToken(
getCapApiEndpointConfigured(event),
getCapSecret(event),
token
)
if (!ok) {
createApiError(event, 'INVALID_REQUEST', '请先完成人机验证', 400)
}
}
+31
View File
@@ -0,0 +1,31 @@
interface CapVerifyResult {
success?: boolean
}
export async function verifyCapToken(
endpoint: string,
secret: string,
token: string
): Promise<boolean> {
if (!endpoint || !secret || !token) return false
const base = endpoint.endsWith('/') ? endpoint : `${endpoint}/`
const url = `${base}siteverify`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, response: token }),
signal: controller.signal
})
const result = await response.json() as CapVerifyResult
return result.success === true
} catch {
return false
} finally {
clearTimeout(timeout)
}
}
+36
View File
@@ -0,0 +1,36 @@
interface TurnstileVerifyResult {
success?: boolean
}
export async function verifyTurnstileToken(
secret: string,
token: string,
remoteIp?: string
): Promise<boolean> {
if (!secret || !token) return false
const params = new URLSearchParams({
secret,
response: token
})
if (remoteIp) {
params.set('remoteip', remoteIp)
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: controller.signal
})
const result = await response.json() as TurnstileVerifyResult
return result.success === true
} catch {
return false
} finally {
clearTimeout(timeout)
}
}