crafy-captcha 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crafy_captcha-1.0.0/PKG-INFO +28 -0
- crafy_captcha-1.0.0/README.md +3 -0
- crafy_captcha-1.0.0/setup.cfg +4 -0
- crafy_captcha-1.0.0/setup.py +28 -0
- crafy_captcha-1.0.0/src/crafy_captcha/__init__.py +1 -0
- crafy_captcha-1.0.0/src/crafy_captcha/crafy_captcha.py +499 -0
- crafy_captcha-1.0.0/src/crafy_captcha.egg-info/PKG-INFO +28 -0
- crafy_captcha-1.0.0/src/crafy_captcha.egg-info/SOURCES.txt +9 -0
- crafy_captcha-1.0.0/src/crafy_captcha.egg-info/dependency_links.txt +1 -0
- crafy_captcha-1.0.0/src/crafy_captcha.egg-info/requires.txt +3 -0
- crafy_captcha-1.0.0/src/crafy_captcha.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crafy-captcha
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official CrafyCAPTCHA Backend SDK for Python
|
|
5
|
+
Home-page: https://github.com/crafycaptcha/crafy-captcha-python
|
|
6
|
+
Author: CrafyCAPTCHA
|
|
7
|
+
Author-email: hello@captcha.crafy.net
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.6
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: requests>=2.25.1
|
|
14
|
+
Requires-Dist: PyNaCl>=1.4.0
|
|
15
|
+
Requires-Dist: cryptography>=3.4.0
|
|
16
|
+
Dynamic: author
|
|
17
|
+
Dynamic: author-email
|
|
18
|
+
Dynamic: classifier
|
|
19
|
+
Dynamic: description
|
|
20
|
+
Dynamic: description-content-type
|
|
21
|
+
Dynamic: home-page
|
|
22
|
+
Dynamic: requires-dist
|
|
23
|
+
Dynamic: requires-python
|
|
24
|
+
Dynamic: summary
|
|
25
|
+
|
|
26
|
+
Official documentation in https://captcha.crafy.net/docs/
|
|
27
|
+
|
|
28
|
+
`pip install crafy-captcha`
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
with open("README.md", "r", encoding="utf-8") as fh:
|
|
4
|
+
long_description = fh.read()
|
|
5
|
+
|
|
6
|
+
setup(
|
|
7
|
+
name="crafy-captcha",
|
|
8
|
+
version="1.0.0",
|
|
9
|
+
author="CrafyCAPTCHA",
|
|
10
|
+
author_email="hello@captcha.crafy.net",
|
|
11
|
+
description="Official CrafyCAPTCHA Backend SDK for Python",
|
|
12
|
+
long_description=long_description,
|
|
13
|
+
long_description_content_type="text/markdown",
|
|
14
|
+
url="https://github.com/crafycaptcha/crafy-captcha-python",
|
|
15
|
+
package_dir={"": "src"},
|
|
16
|
+
packages=find_packages(where="src"),
|
|
17
|
+
install_requires=[
|
|
18
|
+
"requests>=2.25.1",
|
|
19
|
+
"PyNaCl>=1.4.0",
|
|
20
|
+
"cryptography>=3.4.0"
|
|
21
|
+
],
|
|
22
|
+
classifiers=[
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
],
|
|
27
|
+
python_requires=">=3.6",
|
|
28
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .crafy_captcha import CrafyCAPTCHA
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import hmac
|
|
5
|
+
import hashlib
|
|
6
|
+
import base64
|
|
7
|
+
import tempfile
|
|
8
|
+
import glob
|
|
9
|
+
import random
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import requests
|
|
15
|
+
except ImportError:
|
|
16
|
+
raise ImportError("CrafyCAPTCHA requiere 'requests'. Instálalo con: pip install requests")
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import nacl.bindings
|
|
20
|
+
import nacl.hash
|
|
21
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
22
|
+
from cryptography.hazmat.backends import default_backend
|
|
23
|
+
except ImportError:
|
|
24
|
+
raise ImportError("CrafyCAPTCHA requiere librerías criptográficas. Instálalas con: pip install pynacl cryptography")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Cryptor:
|
|
28
|
+
"""
|
|
29
|
+
Clase interna que encapsula la lógica criptográfica (Equivalente a la clase anónima de PHP)
|
|
30
|
+
"""
|
|
31
|
+
ENCRYPTION_ALGORITHM = 'AES-256-CBC'
|
|
32
|
+
HASHING_ALGORITHM = 'sha256'
|
|
33
|
+
|
|
34
|
+
# Constantes Sodium
|
|
35
|
+
SALT_LEN = 16 # SODIUM_CRYPTO_PWHASH_SALTBYTES
|
|
36
|
+
KEY_LEN = 32 # SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES
|
|
37
|
+
NONCE_LEN = 24 # SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES
|
|
38
|
+
|
|
39
|
+
def __init__(self, secret: str):
|
|
40
|
+
if not secret:
|
|
41
|
+
raise ValueError("Secret no puede ser vacío.")
|
|
42
|
+
self.secret = secret.encode('utf-8')
|
|
43
|
+
|
|
44
|
+
# Derivación de llaves pre-calculadas (BLAKE2b y SHA256)
|
|
45
|
+
self.v3_key = nacl.hash.generichash(self.secret, digest_size=self.KEY_LEN)
|
|
46
|
+
self.v1_key = hashlib.sha256(self.secret).digest()
|
|
47
|
+
|
|
48
|
+
def encrypt(self, plaintext: str, version: int = 3) -> str:
|
|
49
|
+
pt_bytes = plaintext.encode('utf-8')
|
|
50
|
+
|
|
51
|
+
if version == 3:
|
|
52
|
+
nonce = os.urandom(self.NONCE_LEN)
|
|
53
|
+
ciphertext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
|
54
|
+
pt_bytes, b'', nonce, self.v3_key
|
|
55
|
+
)
|
|
56
|
+
# PyNaCl ya retorna el MAC adjunto al final del ciphertext, igual que PHP
|
|
57
|
+
out = nonce + ciphertext
|
|
58
|
+
return ';v3_;' + base64.b64encode(out).decode('utf-8')
|
|
59
|
+
|
|
60
|
+
elif version == 2:
|
|
61
|
+
salt = os.urandom(self.SALT_LEN)
|
|
62
|
+
key = nacl.bindings.crypto_pwhash(
|
|
63
|
+
self.KEY_LEN,
|
|
64
|
+
self.secret,
|
|
65
|
+
salt,
|
|
66
|
+
nacl.bindings.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
|
67
|
+
nacl.bindings.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
|
68
|
+
nacl.bindings.crypto_pwhash_ALG_DEFAULT
|
|
69
|
+
)
|
|
70
|
+
nonce = os.urandom(self.NONCE_LEN)
|
|
71
|
+
ciphertext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
|
72
|
+
pt_bytes, b'', nonce, key
|
|
73
|
+
)
|
|
74
|
+
out = salt + nonce + ciphertext
|
|
75
|
+
return ';v2_;' + base64.b64encode(out).decode('utf-8')
|
|
76
|
+
|
|
77
|
+
else:
|
|
78
|
+
iv = os.urandom(16)
|
|
79
|
+
cipher = Cipher(algorithms.AES(self.v1_key), modes.CBC(iv), backend=default_backend())
|
|
80
|
+
encryptor = cipher.encryptor()
|
|
81
|
+
|
|
82
|
+
# Padding PKCS7
|
|
83
|
+
pad_len = 16 - (len(pt_bytes) % 16)
|
|
84
|
+
padded_pt = pt_bytes + bytes([pad_len] * pad_len)
|
|
85
|
+
|
|
86
|
+
cipher_text = encryptor.update(padded_pt) + encryptor.finalize()
|
|
87
|
+
mac = hmac.new(self.v1_key, cipher_text, hashlib.sha256).digest()
|
|
88
|
+
|
|
89
|
+
return (iv + mac + cipher_text).hex()
|
|
90
|
+
|
|
91
|
+
def decrypt(self, input_str: str) -> str:
|
|
92
|
+
try:
|
|
93
|
+
first_chars = input_str[:5]
|
|
94
|
+
|
|
95
|
+
if first_chars == ';v3_;':
|
|
96
|
+
decoded = base64.b64decode(input_str[5:])
|
|
97
|
+
if len(decoded) < self.NONCE_LEN:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
nonce = decoded[:self.NONCE_LEN]
|
|
101
|
+
ciphertext = decoded[self.NONCE_LEN:]
|
|
102
|
+
|
|
103
|
+
plaintext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
|
104
|
+
ciphertext, b'', nonce, self.v3_key
|
|
105
|
+
)
|
|
106
|
+
return plaintext.decode('utf-8')
|
|
107
|
+
|
|
108
|
+
elif first_chars == ';v2_;':
|
|
109
|
+
decoded = base64.b64decode(input_str[5:])
|
|
110
|
+
if len(decoded) < (self.SALT_LEN + self.NONCE_LEN + 1):
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
salt = decoded[:self.SALT_LEN]
|
|
114
|
+
nonce = decoded[self.SALT_LEN : self.SALT_LEN + self.NONCE_LEN]
|
|
115
|
+
ciphertext = decoded[self.SALT_LEN + self.NONCE_LEN :]
|
|
116
|
+
|
|
117
|
+
key = nacl.bindings.crypto_pwhash(
|
|
118
|
+
self.KEY_LEN,
|
|
119
|
+
self.secret,
|
|
120
|
+
salt,
|
|
121
|
+
nacl.bindings.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
|
122
|
+
nacl.bindings.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
|
123
|
+
nacl.bindings.crypto_pwhash_ALG_DEFAULT
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
plaintext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
|
127
|
+
ciphertext, b'', nonce, key
|
|
128
|
+
)
|
|
129
|
+
return plaintext.decode('utf-8')
|
|
130
|
+
|
|
131
|
+
else:
|
|
132
|
+
if len(input_str) % 2 != 0 or not re.match(r'^[0-9a-fA-F]+$', input_str):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
binary_input = bytes.fromhex(input_str)
|
|
136
|
+
if len(binary_input) < 48:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
iv = binary_input[:16]
|
|
140
|
+
mac = binary_input[16:48]
|
|
141
|
+
cipher_text = binary_input[48:]
|
|
142
|
+
|
|
143
|
+
calculated_mac = hmac.new(self.v1_key, cipher_text, hashlib.sha256).digest()
|
|
144
|
+
|
|
145
|
+
if not hmac.compare_digest(mac, calculated_mac):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
cipher = Cipher(algorithms.AES(self.v1_key), modes.CBC(iv), backend=default_backend())
|
|
149
|
+
decryptor = cipher.decryptor()
|
|
150
|
+
padded_pt = decryptor.update(cipher_text) + decryptor.finalize()
|
|
151
|
+
|
|
152
|
+
# Eliminar Padding PKCS7
|
|
153
|
+
pad_len = padded_pt[-1]
|
|
154
|
+
plaintext = padded_pt[:-pad_len]
|
|
155
|
+
return plaintext.decode('utf-8')
|
|
156
|
+
|
|
157
|
+
except Exception:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class CrafyCAPTCHA:
|
|
162
|
+
def __init__(self, public_key: str, secret_key: str, base_url: str = 'https://captcha.crafy.net/api'):
|
|
163
|
+
self.public_key = public_key
|
|
164
|
+
self.secret_key = secret_key
|
|
165
|
+
self.base_url = base_url.rstrip('/')
|
|
166
|
+
|
|
167
|
+
# Configuración del cliente HTTP
|
|
168
|
+
self.timeout = 10
|
|
169
|
+
|
|
170
|
+
# Configuración de Exponential Backoff
|
|
171
|
+
self.max_retries = 3
|
|
172
|
+
self.base_delay_ms = 500
|
|
173
|
+
self.retry_status_codes = [429, 500, 502, 503, 504]
|
|
174
|
+
|
|
175
|
+
# Estado interno
|
|
176
|
+
self.access_token = None
|
|
177
|
+
self.last_flow_verify_error = None
|
|
178
|
+
self._cryptor = _Cryptor(self.secret_key)
|
|
179
|
+
|
|
180
|
+
self.set_temp_dir(tempfile.gettempdir())
|
|
181
|
+
|
|
182
|
+
def set_temp_dir(self, path: str):
|
|
183
|
+
hash_str = hashlib.md5((self.public_key + self.secret_key).encode('utf-8')).hexdigest()
|
|
184
|
+
self.cache_file = os.path.join(path, f'crafy_token_{hash_str}.json')
|
|
185
|
+
|
|
186
|
+
self.nonce_dir = os.path.join(path, 'crafy_nonces')
|
|
187
|
+
os.makedirs(self.nonce_dir, mode=0o777, exist_ok=True)
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
def set_max_retries(self, retries: int):
|
|
191
|
+
self.max_retries = max(0, retries)
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def set_base_delay_ms(self, milliseconds: int):
|
|
195
|
+
self.base_delay_ms = max(0, milliseconds)
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def set_retry_status_codes(self, codes: list):
|
|
199
|
+
self.retry_status_codes = codes
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
def create_flow(self, options: dict = None) -> str:
|
|
203
|
+
"""
|
|
204
|
+
Crea un nuevo Flow seguro para el cliente.
|
|
205
|
+
Genera un nonce criptográfico, lo guarda localmente y retorna las opciones encriptadas.
|
|
206
|
+
"""
|
|
207
|
+
if options is None:
|
|
208
|
+
options = {}
|
|
209
|
+
|
|
210
|
+
# 1. Generar Nonce criptográficamente seguro
|
|
211
|
+
nonce = os.urandom(32).hex()
|
|
212
|
+
|
|
213
|
+
# 2. Guardar el Nonce en archivo temporal (Lock file)
|
|
214
|
+
nonce_file = os.path.join(self.nonce_dir, f'nonce_{nonce}.lock')
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
with open(nonce_file, 'w') as f:
|
|
218
|
+
f.write(str(int(time.time())))
|
|
219
|
+
except IOError:
|
|
220
|
+
raise Exception("CrafyCAPTCHA: No se pudo escribir el archivo nonce temporal.")
|
|
221
|
+
|
|
222
|
+
# 3. Preparar las opciones e inyectar el nonce
|
|
223
|
+
flow_data = options.copy()
|
|
224
|
+
flow_data['nonce'] = nonce
|
|
225
|
+
json_options = json.dumps(flow_data)
|
|
226
|
+
|
|
227
|
+
# 4. Encriptar
|
|
228
|
+
return self._cryptor.encrypt(json_options)
|
|
229
|
+
|
|
230
|
+
def verify_flow(self, base64_payload: str) -> bool:
|
|
231
|
+
"""
|
|
232
|
+
Verifica un Flow completado sin llamar a la API externa.
|
|
233
|
+
Valida firma HMAC, expiración y consume el Nonce (Anti-Replay).
|
|
234
|
+
"""
|
|
235
|
+
self.last_flow_verify_error = None
|
|
236
|
+
|
|
237
|
+
if not base64_payload:
|
|
238
|
+
self.last_flow_verify_error = 'El token está vacío.'
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
# 1. Decodificar el sobre
|
|
242
|
+
try:
|
|
243
|
+
json_envelope = base64.b64decode(base64_payload).decode('utf-8')
|
|
244
|
+
envelope = json.loads(json_envelope)
|
|
245
|
+
except Exception:
|
|
246
|
+
self.last_flow_verify_error = 'No se pudo decodificar el token.'
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
payload_json = envelope.get('payload')
|
|
250
|
+
signature = envelope.get('server_sign')
|
|
251
|
+
|
|
252
|
+
if not payload_json or not signature:
|
|
253
|
+
self.last_flow_verify_error = 'Token malformado.'
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
# 2. Validar Firma (HMAC SHA256)
|
|
257
|
+
expected_signature = hmac.new(
|
|
258
|
+
self.secret_key.encode('utf-8'),
|
|
259
|
+
payload_json.encode('utf-8'),
|
|
260
|
+
hashlib.sha256
|
|
261
|
+
).hexdigest()
|
|
262
|
+
|
|
263
|
+
if not hmac.compare_digest(expected_signature, signature):
|
|
264
|
+
self.last_flow_verify_error = 'Firma de seguridad inválida.'
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
# 3. Decodificar Payload Interno
|
|
268
|
+
try:
|
|
269
|
+
data = json.loads(payload_json)
|
|
270
|
+
except Exception:
|
|
271
|
+
self.last_flow_verify_error = 'No se pudo decodificar el payload interno.'
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
# 4. Validar Estado
|
|
275
|
+
if data.get('status') != 'success':
|
|
276
|
+
self.last_flow_verify_error = 'Estado de Flow inválido.'
|
|
277
|
+
return False
|
|
278
|
+
|
|
279
|
+
# 5. Validar Expiración (UTC)
|
|
280
|
+
expires_at_str = data.get('expires_at')
|
|
281
|
+
if not expires_at_str:
|
|
282
|
+
self.last_flow_verify_error = 'Fecha de expiración no definida.'
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
# Reemplazar Z por +00:00 para compatibilidad con fromisoformat en versiones viejas de Python
|
|
287
|
+
clean_date = expires_at_str.replace('Z', '+00:00')
|
|
288
|
+
expires_at = datetime.fromisoformat(clean_date)
|
|
289
|
+
now = datetime.now(timezone.utc)
|
|
290
|
+
|
|
291
|
+
if now > expires_at:
|
|
292
|
+
self.last_flow_verify_error = 'Token expirado.'
|
|
293
|
+
return False
|
|
294
|
+
except Exception:
|
|
295
|
+
self.last_flow_verify_error = 'Fecha de expiración inválida.'
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
# 6. Validar Nonce (Protección Anti-Replay)
|
|
299
|
+
nonce_encrypted = data.get('nonce')
|
|
300
|
+
if not nonce_encrypted:
|
|
301
|
+
self.last_flow_verify_error = 'Nonce no encontrado.'
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
decrypted_nonce = self._cryptor.decrypt(nonce_encrypted)
|
|
305
|
+
|
|
306
|
+
if not decrypted_nonce:
|
|
307
|
+
self.last_flow_verify_error = 'No se pudo decodificar el nonce.'
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
clean_nonce = re.sub(r'[^a-f0-9]', '', decrypted_nonce)
|
|
311
|
+
if clean_nonce != decrypted_nonce:
|
|
312
|
+
self.last_flow_verify_error = 'Nonce inválido.'
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
nonce_file = os.path.join(self.nonce_dir, f'nonce_{clean_nonce}.lock')
|
|
316
|
+
|
|
317
|
+
# Intento de borrado atómico
|
|
318
|
+
try:
|
|
319
|
+
os.unlink(nonce_file)
|
|
320
|
+
except OSError:
|
|
321
|
+
self.last_flow_verify_error = 'Nonce ya utilizado (Replay Attack).'
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
# 7. Garbage Collection: siempre si >50 archivos, o 1/100 aleatorio
|
|
325
|
+
nonce_files = glob.glob(os.path.join(self.nonce_dir, 'nonce_*.lock'))
|
|
326
|
+
if len(nonce_files) > 50 or random.randint(1, 100) == 1:
|
|
327
|
+
self._garbage_collect_nonces(nonce_files)
|
|
328
|
+
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
def get_last_flow_verify_error(self) -> str:
|
|
332
|
+
return self.last_flow_verify_error
|
|
333
|
+
|
|
334
|
+
def _garbage_collect_nonces(self, files: list = None):
|
|
335
|
+
if files is None:
|
|
336
|
+
files = glob.glob(os.path.join(self.nonce_dir, 'nonce_*.lock'))
|
|
337
|
+
|
|
338
|
+
now = time.time()
|
|
339
|
+
for file_path in files:
|
|
340
|
+
try:
|
|
341
|
+
if os.path.isfile(file_path) and (now - os.path.getmtime(file_path) > 1200): # 20 min TTL
|
|
342
|
+
os.unlink(file_path)
|
|
343
|
+
except OSError:
|
|
344
|
+
pass
|
|
345
|
+
|
|
346
|
+
def clear_all_nonces(self) -> int:
|
|
347
|
+
files = glob.glob(os.path.join(self.nonce_dir, 'nonce_*.lock'))
|
|
348
|
+
count = 0
|
|
349
|
+
for file_path in files:
|
|
350
|
+
try:
|
|
351
|
+
if os.path.isfile(file_path):
|
|
352
|
+
os.unlink(file_path)
|
|
353
|
+
count += 1
|
|
354
|
+
except OSError:
|
|
355
|
+
pass
|
|
356
|
+
return count
|
|
357
|
+
|
|
358
|
+
def call(self, action: str, data: dict = None) -> dict:
|
|
359
|
+
if data is None:
|
|
360
|
+
data = {}
|
|
361
|
+
|
|
362
|
+
self._ensure_auth()
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
return self._send_request(action, data, True)
|
|
366
|
+
except Exception as e:
|
|
367
|
+
# Verificar si es un error 401
|
|
368
|
+
if getattr(e, 'status_code', None) == 401 or '401' in str(e):
|
|
369
|
+
self._clear_cache()
|
|
370
|
+
self._ensure_auth(force_refresh=True)
|
|
371
|
+
return self._send_request(action, data, True)
|
|
372
|
+
raise e
|
|
373
|
+
|
|
374
|
+
def _ensure_auth(self, force_refresh: bool = False):
|
|
375
|
+
if not force_refresh and self.access_token:
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
if not force_refresh and os.path.exists(self.cache_file):
|
|
379
|
+
try:
|
|
380
|
+
with open(self.cache_file, 'r') as f:
|
|
381
|
+
cached = json.load(f)
|
|
382
|
+
if cached.get('token') and cached.get('expires_at') and time.time() < (cached['expires_at'] - 60):
|
|
383
|
+
self.access_token = cached['token']
|
|
384
|
+
return
|
|
385
|
+
except Exception:
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
auth_payload = {'public_key': self.public_key, 'secret_key': self.secret_key}
|
|
389
|
+
response = self._send_request('authenticate', auth_payload, False)
|
|
390
|
+
|
|
391
|
+
if not response.get('token'):
|
|
392
|
+
raise Exception("CrafyCAPTCHA SDK: No se recibió token de autenticación.")
|
|
393
|
+
|
|
394
|
+
self.access_token = response['token']
|
|
395
|
+
expires_in = int(response.get('expires_in', 3600))
|
|
396
|
+
self._save_cache(self.access_token, int(time.time()) + expires_in)
|
|
397
|
+
|
|
398
|
+
def _save_cache(self, token: str, expires_at: int):
|
|
399
|
+
data = json.dumps({'token': token, 'expires_at': expires_at})
|
|
400
|
+
try:
|
|
401
|
+
with open(self.cache_file, 'w') as f:
|
|
402
|
+
f.write(data)
|
|
403
|
+
os.chmod(self.cache_file, 0o600)
|
|
404
|
+
except IOError:
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
def _clear_cache(self):
|
|
408
|
+
self.access_token = None
|
|
409
|
+
if os.path.exists(self.cache_file):
|
|
410
|
+
try:
|
|
411
|
+
os.unlink(self.cache_file)
|
|
412
|
+
except OSError:
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
def _send_request(self, action: str, data: dict, use_auth: bool) -> dict:
|
|
416
|
+
url = f"{self.base_url}/?action={action}"
|
|
417
|
+
|
|
418
|
+
headers = {
|
|
419
|
+
'Content-Type': 'application/json',
|
|
420
|
+
'Accept': 'application/json',
|
|
421
|
+
'User-Agent': 'CrafyCAPTCHA-Python-SDK/2.2'
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if use_auth and self.access_token:
|
|
425
|
+
headers['Authorization'] = f"Bearer {self.access_token}"
|
|
426
|
+
|
|
427
|
+
attempt = 0
|
|
428
|
+
max_attempts = self.max_retries + 1
|
|
429
|
+
|
|
430
|
+
while attempt < max_attempts:
|
|
431
|
+
attempt += 1
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
response = requests.post(url, json=data, headers=headers, timeout=self.timeout)
|
|
435
|
+
http_code = response.status_code
|
|
436
|
+
|
|
437
|
+
should_retry = http_code in self.retry_status_codes
|
|
438
|
+
|
|
439
|
+
if should_retry and attempt < max_attempts:
|
|
440
|
+
delay_us = 0
|
|
441
|
+
retry_after = response.headers.get('Retry-After')
|
|
442
|
+
|
|
443
|
+
if retry_after:
|
|
444
|
+
if retry_after.isdigit():
|
|
445
|
+
delay_us = int(retry_after) * 1000000
|
|
446
|
+
else:
|
|
447
|
+
try:
|
|
448
|
+
# Parsing HTTP date
|
|
449
|
+
from email.utils import parsedate_to_datetime
|
|
450
|
+
dt = parsedate_to_datetime(retry_after)
|
|
451
|
+
delta = (dt - datetime.now(timezone.utc)).total_seconds()
|
|
452
|
+
if delta > 0:
|
|
453
|
+
delay_us = int(delta * 1000000)
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
if delay_us <= 0:
|
|
458
|
+
delay_us = int((self.base_delay_ms * 1000) * (2 ** (attempt - 1)))
|
|
459
|
+
|
|
460
|
+
time.sleep(delay_us / 1000000.0)
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
if http_code == 401:
|
|
464
|
+
error = Exception("Unauthorized")
|
|
465
|
+
error.status_code = 401
|
|
466
|
+
raise error
|
|
467
|
+
|
|
468
|
+
# Intentar parsear JSON
|
|
469
|
+
try:
|
|
470
|
+
json_resp = response.json()
|
|
471
|
+
except ValueError:
|
|
472
|
+
if http_code >= 400:
|
|
473
|
+
error = Exception(f"CrafyCAPTCHA HTTP Error ({http_code})")
|
|
474
|
+
error.status_code = http_code
|
|
475
|
+
raise error
|
|
476
|
+
raise Exception(f"CrafyCAPTCHA API Error: Respuesta inválida. HTTP Code: {http_code}")
|
|
477
|
+
|
|
478
|
+
if json_resp.get('status') == 'error':
|
|
479
|
+
msg = json_resp.get('message', 'Error desconocido')
|
|
480
|
+
error = Exception(msg)
|
|
481
|
+
error.status_code = http_code
|
|
482
|
+
raise error
|
|
483
|
+
|
|
484
|
+
if http_code >= 400:
|
|
485
|
+
error = Exception(f"CrafyCAPTCHA HTTP Error ({http_code})")
|
|
486
|
+
error.status_code = http_code
|
|
487
|
+
raise error
|
|
488
|
+
|
|
489
|
+
return json_resp.get('data', {})
|
|
490
|
+
|
|
491
|
+
except requests.exceptions.RequestException as e:
|
|
492
|
+
if attempt >= max_attempts:
|
|
493
|
+
raise Exception(f"CrafyCAPTCHA Network Error: {str(e)}")
|
|
494
|
+
|
|
495
|
+
# Backoff simple por error de red
|
|
496
|
+
delay = (self.base_delay_ms / 1000.0) * (2 ** (attempt - 1))
|
|
497
|
+
time.sleep(delay)
|
|
498
|
+
|
|
499
|
+
raise Exception("CrafyCAPTCHA: Max retries exceeded.")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crafy-captcha
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official CrafyCAPTCHA Backend SDK for Python
|
|
5
|
+
Home-page: https://github.com/crafycaptcha/crafy-captcha-python
|
|
6
|
+
Author: CrafyCAPTCHA
|
|
7
|
+
Author-email: hello@captcha.crafy.net
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.6
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: requests>=2.25.1
|
|
14
|
+
Requires-Dist: PyNaCl>=1.4.0
|
|
15
|
+
Requires-Dist: cryptography>=3.4.0
|
|
16
|
+
Dynamic: author
|
|
17
|
+
Dynamic: author-email
|
|
18
|
+
Dynamic: classifier
|
|
19
|
+
Dynamic: description
|
|
20
|
+
Dynamic: description-content-type
|
|
21
|
+
Dynamic: home-page
|
|
22
|
+
Dynamic: requires-dist
|
|
23
|
+
Dynamic: requires-python
|
|
24
|
+
Dynamic: summary
|
|
25
|
+
|
|
26
|
+
Official documentation in https://captcha.crafy.net/docs/
|
|
27
|
+
|
|
28
|
+
`pip install crafy-captcha`
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
setup.py
|
|
3
|
+
src/crafy_captcha/__init__.py
|
|
4
|
+
src/crafy_captcha/crafy_captcha.py
|
|
5
|
+
src/crafy_captcha.egg-info/PKG-INFO
|
|
6
|
+
src/crafy_captcha.egg-info/SOURCES.txt
|
|
7
|
+
src/crafy_captcha.egg-info/dependency_links.txt
|
|
8
|
+
src/crafy_captcha.egg-info/requires.txt
|
|
9
|
+
src/crafy_captcha.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
crafy_captcha
|