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
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:
1 parent
8408d01197
commit
430c4de0f4
42 files changed
+1103
-72
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { isAllowRegistration } from '../../utils/db'
|
||||
import { isAdminSecretConfigured, isApiUploadTokenConfigured } from '../../utils/env'
|
||||
import { getLoginVerificationPublicConfig, isAdminSecretConfigured, isApiUploadTokenConfigured } from '../../utils/env'
|
||||
import { isInitialized } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
@@ -8,6 +8,7 @@ export default defineEventHandler((event) => {
|
||||
allowRegistration: isAllowRegistration(),
|
||||
legacyMode: !isInitialized() && isAdminSecretConfigured(event),
|
||||
adminSecretConfigured: isAdminSecretConfigured(event),
|
||||
apiUploadTokenConfigured: isApiUploadTokenConfigured(event)
|
||||
apiUploadTokenConfigured: isApiUploadTokenConfigured(event),
|
||||
loginVerification: getLoginVerificationPublicConfig(event)
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import {
|
||||
checkAdminSecret,
|
||||
setLegacyAuthCookie
|
||||
@@ -7,19 +6,18 @@ import { createApiError } from '../../utils/api-error'
|
||||
import {
|
||||
createSession,
|
||||
isInitialized,
|
||||
rowToAuthUser,
|
||||
verifyPassword
|
||||
} from '../../utils/auth'
|
||||
import { findUserByUsername } from '../../utils/db'
|
||||
import { getAdminSecret } from '../../utils/env'
|
||||
import { verifyLoginCaptcha } from '../../utils/login-captcha'
|
||||
import { verifyLoginVerification, type VerificationBody } from '../../utils/login-verification'
|
||||
import { clientIp, logInfo, logWarn } from '../../utils/logger'
|
||||
|
||||
interface LoginBody {
|
||||
interface LoginBody extends VerificationBody {
|
||||
username?: string
|
||||
password?: string
|
||||
secret?: string
|
||||
captchaId?: string
|
||||
captchaPosition?: number
|
||||
}
|
||||
|
||||
const loginAttempts = new Map<string, { count: number, resetAt: number }>()
|
||||
@@ -42,20 +40,12 @@ function checkRateLimit(ip: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function verifyCaptchaOrFail(event: H3Event, body: LoginBody) {
|
||||
const id = body.captchaId?.trim() ?? ''
|
||||
const position = Number(body.captchaPosition)
|
||||
if (!id || !verifyLoginCaptcha(id, position)) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请先完成滑块验证', 400)
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const ip = clientIp(event)
|
||||
checkRateLimit(ip)
|
||||
|
||||
const body = await readBody<LoginBody>(event)
|
||||
verifyCaptchaOrFail(event, body ?? {})
|
||||
await verifyLoginVerification(event, body ?? {})
|
||||
const initialized = isInitialized()
|
||||
|
||||
if (!initialized) {
|
||||
@@ -94,6 +84,6 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username, role: user.role }
|
||||
user: rowToAuthUser(user)
|
||||
}
|
||||
})
|
||||
@@ -9,6 +9,10 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
user: { id: user.id, username: user.username, role: user.role }
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
isInitialized
|
||||
} from '../../utils/auth'
|
||||
import { isAllowRegistration } from '../../utils/db'
|
||||
import { verifyLoginVerification, type VerificationBody } from '../../utils/login-verification'
|
||||
import { clientIp, logInfo } from '../../utils/logger'
|
||||
|
||||
interface RegisterBody {
|
||||
interface RegisterBody extends VerificationBody {
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
@@ -22,6 +23,8 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const body = await readBody<RegisterBody>(event)
|
||||
await verifyLoginVerification(event, body ?? {})
|
||||
|
||||
const username = body?.username?.trim() ?? ''
|
||||
const password = body?.password ?? ''
|
||||
|
||||
@@ -38,7 +41,11 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username, role: user.role }
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isAllowRegistration } from '../../utils/db'
|
||||
import { isAdminSecretConfigured } from '../../utils/env'
|
||||
import { getLoginVerificationPublicConfig, isAdminSecretConfigured } from '../../utils/env'
|
||||
import {
|
||||
getCurrentUser,
|
||||
isInitialized
|
||||
@@ -17,8 +17,13 @@ export default defineEventHandler(async (event) => {
|
||||
allowRegistration: isAllowRegistration(),
|
||||
legacyMode,
|
||||
needsMigration,
|
||||
loginVerification: getLoginVerificationPublicConfig(event),
|
||||
user: user
|
||||
? { id: user.id, username: user.username, role: user.role }
|
||||
? {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
@@ -9,6 +9,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,
|
||||
setAllowRegistration
|
||||
} from '../../utils/db'
|
||||
import {
|
||||
@@ -16,6 +21,8 @@ import {
|
||||
isValidImageBaseUrl,
|
||||
isValidRefererHost,
|
||||
isValidSiteBaseUrl,
|
||||
normalizeCapApiEndpoint,
|
||||
parseLoginVerificationMethod,
|
||||
MAX_AUTO_DELETE_DAYS,
|
||||
normalizeImageBaseUrl,
|
||||
normalizeRefererHosts,
|
||||
@@ -23,6 +30,7 @@ import {
|
||||
parseAutoDeleteDays,
|
||||
parseWebpQuality,
|
||||
setGlobalAutoDeletePolicy,
|
||||
validateLoginVerificationSettings,
|
||||
validateSettingsDomainPatch
|
||||
} from '../../utils/env'
|
||||
|
||||
@@ -36,6 +44,11 @@ interface SettingsPatchBody {
|
||||
storageUseDatePath?: unknown
|
||||
autoDeleteDays?: unknown
|
||||
allowRegistration?: unknown
|
||||
loginVerificationMethod?: unknown
|
||||
turnstileSiteKey?: unknown
|
||||
turnstileSecretKey?: unknown
|
||||
capApiEndpoint?: unknown
|
||||
capSecret?: unknown
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -161,5 +174,79 @@ export default defineEventHandler(async (event) => {
|
||||
setSetting(SETTINGS_STORAGE_USE_DATE_PATH, body.storageUseDatePath ? 'true' : 'false')
|
||||
}
|
||||
|
||||
const currentMethod = parseLoginVerificationMethod(
|
||||
getSetting(SETTINGS_LOGIN_VERIFICATION_METHOD) ?? 'slider'
|
||||
) ?? 'slider'
|
||||
let nextMethod = currentMethod
|
||||
let nextTurnstileSiteKey = getSetting(SETTINGS_TURNSTILE_SITE_KEY) ?? ''
|
||||
let nextTurnstileSecretKey = getSetting(SETTINGS_TURNSTILE_SECRET_KEY) ?? ''
|
||||
let nextCapApiEndpoint = getSetting(SETTINGS_CAP_API_ENDPOINT) ?? ''
|
||||
let nextCapSecret = getSetting(SETTINGS_CAP_SECRET) ?? ''
|
||||
|
||||
if (body.loginVerificationMethod !== undefined) {
|
||||
const parsed = parseLoginVerificationMethod(String(body.loginVerificationMethod))
|
||||
if (!parsed) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的登录验证方式', 400)
|
||||
}
|
||||
nextMethod = parsed
|
||||
}
|
||||
|
||||
if (body.turnstileSiteKey !== undefined) {
|
||||
nextTurnstileSiteKey = typeof body.turnstileSiteKey === 'string'
|
||||
? body.turnstileSiteKey.trim()
|
||||
: ''
|
||||
}
|
||||
|
||||
if (body.turnstileSecretKey !== undefined) {
|
||||
nextTurnstileSecretKey = typeof body.turnstileSecretKey === 'string'
|
||||
? body.turnstileSecretKey.trim()
|
||||
: ''
|
||||
}
|
||||
|
||||
if (body.capApiEndpoint !== undefined) {
|
||||
nextCapApiEndpoint = typeof body.capApiEndpoint === 'string'
|
||||
? normalizeCapApiEndpoint(body.capApiEndpoint)
|
||||
: ''
|
||||
}
|
||||
|
||||
if (body.capSecret !== undefined) {
|
||||
nextCapSecret = typeof body.capSecret === 'string'
|
||||
? body.capSecret.trim()
|
||||
: ''
|
||||
}
|
||||
|
||||
if (body.loginVerificationMethod !== undefined
|
||||
|| body.turnstileSiteKey !== undefined
|
||||
|| body.turnstileSecretKey !== undefined
|
||||
|| body.capApiEndpoint !== undefined
|
||||
|| body.capSecret !== undefined) {
|
||||
const settingsError = validateLoginVerificationSettings(
|
||||
nextMethod,
|
||||
nextTurnstileSiteKey,
|
||||
nextTurnstileSecretKey,
|
||||
nextCapApiEndpoint,
|
||||
nextCapSecret
|
||||
)
|
||||
if (settingsError) {
|
||||
createApiError(event, 'INVALID_REQUEST', settingsError, 400)
|
||||
}
|
||||
}
|
||||
|
||||
if (body.loginVerificationMethod !== undefined) {
|
||||
setSetting(SETTINGS_LOGIN_VERIFICATION_METHOD, nextMethod)
|
||||
}
|
||||
if (body.turnstileSiteKey !== undefined) {
|
||||
setSetting(SETTINGS_TURNSTILE_SITE_KEY, nextTurnstileSiteKey)
|
||||
}
|
||||
if (body.turnstileSecretKey !== undefined) {
|
||||
setSetting(SETTINGS_TURNSTILE_SECRET_KEY, nextTurnstileSecretKey)
|
||||
}
|
||||
if (body.capApiEndpoint !== undefined) {
|
||||
setSetting(SETTINGS_CAP_API_ENDPOINT, nextCapApiEndpoint)
|
||||
}
|
||||
if (body.capSecret !== undefined) {
|
||||
setSetting(SETTINGS_CAP_SECRET, nextCapSecret)
|
||||
}
|
||||
|
||||
return getSettingsPayload(event)
|
||||
})
|
||||
@@ -41,7 +41,7 @@ export default defineEventHandler(async (event) => {
|
||||
const userFilter = await getImageUserFilter(event)
|
||||
|
||||
try {
|
||||
if (user.role === 'admin' && userFilter === 'admin') {
|
||||
if (userFilter === 'admin') {
|
||||
const activity = getActivityStats()
|
||||
const [storedCount, byFolder, storageUsage] = await Promise.all([
|
||||
countImages(),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
const SETTING_KEY = 'login_verification_method'
|
||||
const SLIDER = 'slider'
|
||||
|
||||
if (process.argv.length > 2) {
|
||||
console.error('Usage: slider')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const dataDir = process.env.DATA_DIR || join(process.cwd(), 'data')
|
||||
mkdirSync(dataDir, { recursive: true })
|
||||
const db = new DatabaseSync(join(dataDir, 'pichost.db'))
|
||||
|
||||
const row = db.prepare(`
|
||||
SELECT value
|
||||
FROM settings
|
||||
WHERE key = ?
|
||||
`).get(SETTING_KEY)
|
||||
|
||||
const previous = row?.value ?? null
|
||||
const updatedAt = new Date().toISOString()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(SETTING_KEY, SLIDER, updatedAt)
|
||||
|
||||
db.close()
|
||||
|
||||
if (previous === SLIDER) {
|
||||
console.log('[PicHost] Login verification is already set to slider.')
|
||||
} else {
|
||||
console.log(`[PicHost] Login verification changed: ${previous ?? '(unset)'} → slider`)
|
||||
}
|
||||
console.log('[PicHost] Refresh the login page to use the local slider captcha.')
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user