- In den Einstellungen einer Quelle: Zugangsdaten der Seite; der Server prüft die Anmeldung sofort, speichert das Passwort verschlüsselt (Fernet, Schlüssel aus SECRET_KEY) und gibt es nie wieder heraus - Artikel werden mit der angemeldeten Sitzung geholt; hinter der Bezahlschranke bleibt der freie Teil (paywalled) und wird nach der Anmeldung neu geholt - Adapter je Seite (feeds/logins.py), zuerst jungewelt.de - sw v92 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
5.6 KiB
Python
167 lines
5.6 KiB
Python
"""Subscriber logins for sites behind a paywall (junge Welt …).
|
|
|
|
The account holder enters the site's username and password in the feed's
|
|
settings; the server logs in with them and fetches the articles as a
|
|
subscriber. The password is stored encrypted (Fernet, key derived from
|
|
SECRET_KEY — a copy of the database alone doesn't reveal it) and never
|
|
handed out again. Each supported site is an adapter: how to log in, how a
|
|
paywalled page looks.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import logging
|
|
import threading
|
|
import time
|
|
from urllib.parse import urlparse
|
|
|
|
import requests
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
from django.conf import settings
|
|
from django.utils import timezone
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
TIMEOUT = 15
|
|
SESSION_SECONDS = 6 * 3600 # a logged-in session is reused this long, then logs in again
|
|
|
|
|
|
class LoginFailed(Exception):
|
|
pass
|
|
|
|
|
|
class JungeWelt:
|
|
domain = 'jungewelt.de'
|
|
name = 'junge Welt'
|
|
|
|
def login(self, http, username, password):
|
|
r = http.post('https://www.jungewelt.de/login.php', timeout=TIMEOUT, allow_redirects=False,
|
|
data={'username': username, 'password': password, 'ref': '/', 'submit': 'Login'})
|
|
where = r.headers.get('Location', '')
|
|
if 'loginFailed' in where:
|
|
raise LoginFailed('Benutzername oder Passwort falsch')
|
|
if r.status_code not in (301, 302, 303) or not http.cookies.get('jW'):
|
|
raise LoginFailed(f'Anmeldung fehlgeschlagen ({r.status_code})')
|
|
|
|
def paywalled(self, page):
|
|
return 'mit einem Abonnement und erhalten Sie Zugang' in page
|
|
|
|
|
|
ADAPTERS = {a.domain: a for a in (JungeWelt(),)}
|
|
|
|
|
|
def adapter_for(url):
|
|
host = (urlparse(url or '').hostname or '').lower()
|
|
for domain, adapter in ADAPTERS.items():
|
|
if host == domain or host.endswith('.' + domain):
|
|
return adapter
|
|
return None
|
|
|
|
|
|
def _fernet():
|
|
key = hashlib.sha256(b'diora feeds site login\0' + settings.SECRET_KEY.encode()).digest()
|
|
return Fernet(base64.urlsafe_b64encode(key))
|
|
|
|
|
|
def encrypt(text):
|
|
return _fernet().encrypt(text.encode()).decode()
|
|
|
|
|
|
def decrypt(token):
|
|
"""The plain text, or None when it can't be read (SECRET_KEY changed)."""
|
|
try:
|
|
return _fernet().decrypt(token.encode()).decode()
|
|
except (InvalidToken, ValueError):
|
|
return None
|
|
|
|
|
|
def new_http():
|
|
from .fetch import USER_AGENT
|
|
s = requests.Session()
|
|
s.headers['User-Agent'] = USER_AGENT
|
|
return s
|
|
|
|
|
|
# Logged-in sessions in this process: (user id, domain) -> (session, logged in at).
|
|
_sessions = {}
|
|
_lock = threading.Lock()
|
|
|
|
|
|
def forget(user_id, domain):
|
|
with _lock:
|
|
_sessions.pop((user_id, domain), None)
|
|
|
|
|
|
def check_and_store(user, url, username, password):
|
|
"""Log in once; on success store the login (encrypted). Raises LoginFailed."""
|
|
from .models import SiteLogin
|
|
adapter = adapter_for(url)
|
|
if adapter is None:
|
|
raise LoginFailed('Für diese Seite gibt es keine Anmeldung.')
|
|
http = new_http()
|
|
try:
|
|
adapter.login(http, username, password)
|
|
except requests.RequestException:
|
|
raise LoginFailed(f'{adapter.name} ist nicht erreichbar.')
|
|
SiteLogin.objects.update_or_create(user=user, domain=adapter.domain, defaults={
|
|
'username': username, 'secret': encrypt(password), 'ok': True, 'last_error': '',
|
|
'checked_at': timezone.now()})
|
|
with _lock:
|
|
_sessions[(user.id, adapter.domain)] = (http, time.monotonic())
|
|
return adapter
|
|
|
|
|
|
def session_for(user_id, url, fresh=False):
|
|
"""A logged-in requests session for the article's site, or None (no login
|
|
stored, or it no longer works — then the login is marked as failing)."""
|
|
from .models import SiteLogin
|
|
adapter = adapter_for(url)
|
|
if adapter is None:
|
|
return None
|
|
key = (user_id, adapter.domain)
|
|
with _lock:
|
|
cached = _sessions.get(key)
|
|
if cached and not fresh and time.monotonic() - cached[1] < SESSION_SECONDS:
|
|
return cached[0]
|
|
login = SiteLogin.objects.filter(user_id=user_id, domain=adapter.domain).first()
|
|
if login is None:
|
|
return None
|
|
password = decrypt(login.secret)
|
|
if password is None:
|
|
login.ok, login.last_error = False, 'Bitte die Zugangsdaten neu eingeben.'
|
|
login.save(update_fields=['ok', 'last_error'])
|
|
return None
|
|
http = new_http()
|
|
try:
|
|
adapter.login(http, login.username, password)
|
|
except (LoginFailed, requests.RequestException) as e:
|
|
login.ok = False
|
|
login.last_error = str(e) if isinstance(e, LoginFailed) else f'{adapter.name} nicht erreichbar'
|
|
login.checked_at = timezone.now()
|
|
login.save(update_fields=['ok', 'last_error', 'checked_at'])
|
|
log.info('site login %s for user %s failed: %s', adapter.domain, user_id, e)
|
|
return None
|
|
if not login.ok:
|
|
login.ok, login.last_error = True, ''
|
|
login.save(update_fields=['ok', 'last_error'])
|
|
with _lock:
|
|
_sessions[key] = (http, time.monotonic())
|
|
return http
|
|
|
|
|
|
def has_login(user_id, url):
|
|
from .models import SiteLogin
|
|
adapter = adapter_for(url)
|
|
return bool(adapter) and SiteLogin.objects.filter(user_id=user_id, domain=adapter.domain, ok=True).exists()
|
|
|
|
|
|
def info(user_id, url):
|
|
"""What the feed settings show about the login for this source's site."""
|
|
from .models import SiteLogin
|
|
adapter = adapter_for(url)
|
|
if adapter is None:
|
|
return None
|
|
login = SiteLogin.objects.filter(user_id=user_id, domain=adapter.domain).first()
|
|
return {'site': adapter.name, 'domain': adapter.domain,
|
|
'username': login.username if login else '', 'ok': bool(login and login.ok),
|
|
'error': login.last_error if login else ''}
|