309 lines
12 KiB
Python
309 lines
12 KiB
Python
import os
|
|
import json
|
|
import re
|
|
import time
|
|
import base64
|
|
from pathlib import Path
|
|
from dotenv import load_dotenv
|
|
import requests
|
|
import urllib3
|
|
|
|
# ============================================================================
|
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
|
|
load_dotenv()
|
|
|
|
TOKEN_FILE = Path(__file__).parent / "token.json"
|
|
CLIENT_ID = "cd8e7afb-a0e2-4217-9187-88206e4ba7af"
|
|
TOKEN_URL = f"https://api-seguridad.sunat.gob.pe/v1/clientessol/{CLIENT_ID}/oauth2/token/"
|
|
|
|
RUC = os.getenv("SUNAT_RUC", "").strip()
|
|
USER = os.getenv("SUNAT_USER", "").strip()
|
|
PASSWORD = os.getenv("SUNAT_PASSWORD", "").strip()
|
|
J_USERNAME = USER
|
|
|
|
# ============================================================================
|
|
def log(msg):
|
|
print(f" {msg}")
|
|
|
|
# ============================================================================
|
|
def decode_jwt(token):
|
|
try:
|
|
payload_b64 = token.split(".")[1]
|
|
padding = 4 - len(payload_b64) % 4
|
|
if padding != 4:
|
|
payload_b64 += "=" * padding
|
|
return json.loads(base64.b64decode(payload_b64))
|
|
except:
|
|
return None
|
|
|
|
# ============================================================================
|
|
def save_token(token):
|
|
payload = decode_jwt(token) or {}
|
|
expires_at = payload.get("exp", 0)
|
|
data = {
|
|
"token": token,
|
|
"expires_at": expires_at,
|
|
"client_id": payload.get("clientId", ""),
|
|
"ruc": payload.get("sub", ""),
|
|
"usuario_sol": payload.get("userdata", {}).get("usuarioSOL", ""),
|
|
"_source": "login_api.py",
|
|
"_grant_type": payload.get("grantType", ""),
|
|
}
|
|
TOKEN_FILE.write_text(json.dumps(data, indent=2))
|
|
print(f"\n✅ Token guardado en: {TOKEN_FILE}")
|
|
print(f" Expira: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(expires_at))}")
|
|
print(f" RUC: {data['ruc']}")
|
|
print(f" Usuario SOL: {data['usuario_sol']}")
|
|
return True
|
|
|
|
# ============================================================================
|
|
def try_password_grant():
|
|
print("\n🔑 Intentando grant_type=password...")
|
|
data = {
|
|
"grant_type": "password",
|
|
"username": J_USERNAME,
|
|
"password": PASSWORD,
|
|
"client_id": CLIENT_ID,
|
|
}
|
|
headers = {
|
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
try:
|
|
r = requests.post(TOKEN_URL, data=data, headers=headers, timeout=30, verify=False)
|
|
print(f" {r.status_code}")
|
|
if r.status_code == 200:
|
|
j = r.json()
|
|
token = j.get("access_token")
|
|
if token:
|
|
print(f" ✅ Password grant funcionó!")
|
|
save_token(token)
|
|
return True
|
|
print(f" ⚠️ Sin access_token en respuesta: {str(j)[:200]}")
|
|
else:
|
|
print(f" ❌ Response: {r.text[:300]}")
|
|
except Exception as e:
|
|
print(f" ❌ Error: {e}")
|
|
return False
|
|
|
|
# ============================================================================
|
|
def try_auth_code_flow():
|
|
print("\n🔑 Intentando flujo authorization_code...")
|
|
log("1. Accediendo a MenuInternet.htm para obtener redirect...")
|
|
|
|
session = requests.Session()
|
|
session.verify = False
|
|
session.headers.update({
|
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
})
|
|
|
|
menu_url = "https://e-menu.sunat.gob.pe/cl-ti-itmenu/MenuInternet.htm"
|
|
|
|
try:
|
|
r = session.get(menu_url, allow_redirects=True, timeout=30)
|
|
log(f" GET MenuInternet: {r.status_code}")
|
|
log(f" URL final: {r.url}")
|
|
|
|
html = r.text
|
|
(Path(__file__).parent / "descargas" / "menu_response.html").write_text(html)
|
|
|
|
# Extraer URL del redirect() en el JavaScript
|
|
redirect_match = re.search(r'redirect\s*\(\s*["\']([^"\']+)["\']', html)
|
|
if not redirect_match:
|
|
print(" ❌ No se encontró URL de login en MenuInternet.htm")
|
|
return False
|
|
|
|
authen_url = redirect_match.group(1)
|
|
log(f"\n Login URL: {authen_url}")
|
|
|
|
# Extraer state y redirect_uri de la authen URL
|
|
state_match = re.search(r'state=([^&\s"\']+)', authen_url)
|
|
state_from_url = state_match.group(1) if state_match else None
|
|
if state_from_url:
|
|
log(f" state: {state_from_url[:60]}...")
|
|
state_decoded = state_from_url # No decodear: el + de base64 se pierde con unquote
|
|
|
|
redirect_uri_match = re.search(r'redirect_uri=([^&\s"\']+)', authen_url)
|
|
redirect_uri = requests.utils.unquote(redirect_uri_match.group(1)) if redirect_uri_match else None
|
|
|
|
# 2. Acceder a la URL de authen para obtener el formulario de login
|
|
log("\n 2. Accediendo a authen...")
|
|
r2 = session.get(authen_url, timeout=30)
|
|
log(f" GET authen: {r2.status_code}")
|
|
log(f" URL final: {r2.url[:200]}")
|
|
|
|
html2 = r2.text
|
|
(Path(__file__).parent / "descargas" / "login_page.html").write_text(html2)
|
|
|
|
# Extraer state y originalUrl de la URL final (authen los pasa a loginMenuSol)
|
|
final_state_match = re.search(r'[?&]state=([^&\s]+)', r2.url)
|
|
final_orig_match = re.search(r'[?&]originalUrl=([^&\s]+)', r2.url)
|
|
final_state = requests.utils.unquote(final_state_match.group(1)) if final_state_match else state_decoded
|
|
final_orig = requests.utils.unquote(final_orig_match.group(1)) if final_orig_match else redirect_uri
|
|
|
|
# Verificar CAPTCHA real: buscar <img id="imgCaptcha" fuera de tags <script>
|
|
# (el captcha solo se renderiza via JS tras 3 fallos; con requests el JS no corre)
|
|
html_no_script = re.sub(r'<script[^>]*>.*?</script>', '', html2, flags=re.DOTALL | re.IGNORECASE)
|
|
if re.search(r'id=[\'"]imgCaptcha[\'"]', html_no_script):
|
|
print(" ⚠️ CAPTCHA visible detectado (imgCaptcha)")
|
|
return False
|
|
print(" ✅ Sin CAPTCHA visible")
|
|
|
|
# Extraer form action
|
|
action_match = re.search(r'<form[^>]*action=[\'"]([^\'"]+)[\'"]', html2)
|
|
form_action = action_match.group(1) if action_match else "j_security_check"
|
|
log(f" form action: {form_action}")
|
|
|
|
# Construir POST URL (relativo a la URL actual)
|
|
base_path = r2.url.split("?")[0].rsplit("/", 1)[0] + "/"
|
|
if form_action.startswith("http"):
|
|
post_url = form_action
|
|
elif form_action.startswith("/"):
|
|
base = re.match(r'(https?://[^/]+)', r2.url)
|
|
post_url = f"{base.group(1)}{form_action}" if base else form_action
|
|
else:
|
|
post_url = base_path + form_action
|
|
log(f" POST URL: {post_url}")
|
|
|
|
# Extraer lang de la URL actual
|
|
lang_match = re.search(r'lang=([^&\s]+)', r2.url)
|
|
lang = lang_match.group(1) if lang_match else "es-PE"
|
|
|
|
# 3. POST credenciales
|
|
log("\n 3. Enviando credenciales...")
|
|
form_data = {
|
|
"tipo": "2",
|
|
"dni": "",
|
|
"custom_ruc": RUC,
|
|
"j_username": J_USERNAME,
|
|
"j_password": PASSWORD,
|
|
"captcha": "",
|
|
"originalUrl": redirect_uri or "",
|
|
"lang": lang,
|
|
"state": state_decoded or "",
|
|
}
|
|
|
|
r3 = session.post(post_url, data=form_data, allow_redirects=False, timeout=30)
|
|
log(f" POST login: {r3.status_code}")
|
|
location = r3.headers.get("Location", "")
|
|
log(f" Location: {location[:200]}")
|
|
|
|
# 4. Seguir redirects buscando authorization code
|
|
auth_code = None
|
|
next_url = location if r3.status_code in (301, 302, 303, 307, 308) else None
|
|
|
|
for i in range(15):
|
|
if not next_url:
|
|
break
|
|
|
|
if "code=" in next_url:
|
|
code_match = re.search(r'code=([^&]+)', next_url)
|
|
if code_match:
|
|
auth_code = code_match.group(1)
|
|
log(f"\n ✅ Authorization code obtenido en redirect {i+1}!")
|
|
break
|
|
|
|
r4 = session.get(next_url, allow_redirects=False, timeout=30)
|
|
log(f" Redirect {i+1}: {r4.status_code} -> {r4.url[:100]}")
|
|
next_url = r4.headers.get("Location") if r4.status_code in (301, 302, 303, 307, 308) else None
|
|
|
|
if auth_code:
|
|
log(f"\n 4. Siguiendo redirect a AutenticaMenuInternet.htm con session activa...")
|
|
# El SOL portal intercambia el code internamente y genera el menú
|
|
# Seguir la cadena de redirects hasta llegar al menú SOL
|
|
next_url = location
|
|
for i in range(10):
|
|
if not next_url:
|
|
break
|
|
log(f" GET redirect {i+1}: {next_url[:120]}")
|
|
r5 = session.get(next_url, allow_redirects=(i == 0), timeout=30)
|
|
log(f" Status: {r5.status_code}")
|
|
log(f" URL final: {r5.url[:150]}")
|
|
|
|
# Buscar token en la respuesta
|
|
jwt_matches = re.findall(r'eyJ[a-zA-Z0-9_\-]+\.[a-zA-Z0-9_\-]+\.[a-zA-Z0-9_\-]+', r5.text)
|
|
for j in jwt_matches:
|
|
if len(j) > 100:
|
|
print(f"\n ✅ JWT encontrado en HTML!")
|
|
save_token(j)
|
|
return True
|
|
|
|
# Buscar en headers
|
|
for h, v in r5.headers.items():
|
|
if 'token' in h.lower() or 'auth' in h.lower():
|
|
log(f" Header {h}: {v[:100]}")
|
|
|
|
# Buscar en cookies
|
|
for c in session.cookies:
|
|
if 'token' in c.name.lower():
|
|
log(f" Cookie {c.name}: {c.value[:80]}")
|
|
|
|
next_url = r5.headers.get("Location") if r5.status_code in (301, 302, 303, 307, 308) else None
|
|
|
|
# Si no encontramos token en las respuestas, extraerlo del contenido
|
|
# Buscar sessionStorage o localStorage hints
|
|
log(f"\n Buscando token en última respuesta...")
|
|
patterns = [
|
|
r'access_token["\']?\s*[=:]\s*["\']([a-zA-Z0-9_\-\.]{100,})["\']',
|
|
r'token["\']?\s*[=:]\s*["\']([a-zA-Z0-9_\-\.]{100,})["\']',
|
|
r'["\']SUNAT\.token["\']?\s*[=:]\s*["\']([a-zA-Z0-9_\-\.]+)["\']',
|
|
]
|
|
for p in patterns:
|
|
m = re.search(p, r5.text if 'r5' in dir() else '')
|
|
if m:
|
|
print(f"\n ✅ Token encontrado via patrón!")
|
|
save_token(m.group(1))
|
|
return True
|
|
|
|
print(f"\n ❌ Token no encontrado en respuestas HTTP")
|
|
print(f" El flujo completo es: login SOL -> menú -> click item -> token en sessionStorage")
|
|
print(f" Esto requiere JavaScript. Usa: python get_token.py")
|
|
else:
|
|
if location:
|
|
print(f" ❌ No se obtuvo code. Última location: {location[:150]}")
|
|
else:
|
|
print(f" ❌ No hubo redirect después del POST")
|
|
print(f" Respuesta: {r3.text[:500]}")
|
|
|
|
except Exception as e:
|
|
print(f" ❌ Error: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
return False
|
|
|
|
# ============================================================================
|
|
def main():
|
|
print("=" * 60)
|
|
print("🔐 LOGIN SUNAT API (sin Playwright)")
|
|
print("=" * 60)
|
|
print(f"\n📋 Config:")
|
|
print(f" RUC: {RUC}")
|
|
print(f" Usuario: {USER}")
|
|
print(f" Client ID: {CLIENT_ID}")
|
|
print(f" Token URL: {TOKEN_URL}")
|
|
|
|
# Intentar password grant primero
|
|
if try_password_grant():
|
|
return
|
|
|
|
# Si falla, intentar authorization_code flow
|
|
print("\n⚠️ Password grant falló. Intentando authorization_code flow...")
|
|
if try_auth_code_flow():
|
|
return
|
|
|
|
print("\n❌ No se pudo obtener token automáticamente.")
|
|
print(" Posibles causas:")
|
|
print(" - CAPTCHA requerido (usa: python get_token.py)")
|
|
print(" - UUID de cliente incorrecto")
|
|
print(" - Cambios en el flujo OAuth2 de SUNAT")
|
|
print("\n Usa get_token.py como respaldo: python get_token.py")
|
|
|
|
# ============================================================================
|
|
if __name__ == "__main__":
|
|
main()
|
|
# ============================================================================ |