Files
descarga_xml_sunat/login_api.py
T

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()
# ============================================================================