edupage-api icon indicating copy to clipboard operation
edupage-api copied to clipboard

[Bug] Login does not work due to change in website

Open BerengarWLehr opened this issue 3 months ago • 8 comments

Describe the bug I try to login as described in the example

Your code

from edupage_api import Edupage
from edupage_api.exceptions import BadCredentialsException, CaptchaException

edupage = Edupage()
USER = "###"
PASSWORD = "###"
SCHOOL = "###"

try:
    second_factor = edupage.login(USER, PASSWORD, SCHOOL)
    if second_factor is not None:
        user = input("Hit enter when 2FA is done...")
        second_factor.finish()
except BadCredentialsException:
    print("Wrong username or password!")
except CaptchaException:
    print("Captcha required!")

Error message

Traceback (most recent call last):
  File "/home/ber/Projekte/Edupage/main.py", line 10, in <module>
    second_factor = edupage.login(USER, PASSWORD, SCHOOL)
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ber/Projekte/Edupage/.venv/lib/python3.12/site-packages/edupage_api/__init__.py", line 69, in login
    return Login(self).login(username, password, subdomain)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ber/Projekte/Edupage/.venv/lib/python3.12/site-packages/edupage_api/login.py", line 209, in login
    csrf_token = data.split('csrfauth" value="')[1].split('"')[0]
                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^
IndexError: list index out of range```

Expected behavior Login to proceed

Version

  • Edupage API version: 0.12.3
  • Python version: 3.12.3

I assume the error is due to a change of edupage. In login.py:208 html is searched for csrfauth" value=" which looks to me like a form element. But in my version of Edupage the form is build dynamically. Important information are stored in javascript variable

var elem = $j('#jwc897fed5_3c167b31').get(0);
var props = {"username":"##MY USERNAME##","deviceNames":["##MY DEVICE##"],"email":"##MY EMAIL##","edupage":"##MY SCHOOL/SUBDOMAIN##","requestid":"## 40byte hex##","gu":null,"au":"","storedUsers":[]};

BerengarWLehr avatar Nov 14 '25 10:11 BerengarWLehr

Current implementation is working for me, so it is hard for me to simulate your case.

Based on information provided, I did code changes. It would be great if you can test it. Please let me know if this worked for you.

file: login.py

import json
import re
from dataclasses import dataclass
from html import unescape
from html.parser import HTMLParser
from json import JSONDecodeError
from typing import Optional

from edupage_api.exceptions import (
    BadCredentialsException,
    CaptchaException,
    MissingDataException,
    RequestError,
    SecondFactorFailedException,
)
from edupage_api.module import EdupageModule, Module


@dataclass
class TwoFactorLogin:
    __authentication_endpoint: str
    __authentication_token: str
    __csrf_token: str
    __edupage: EdupageModule

    __code: Optional[str] = None

    def is_confirmed(self):
        """Check if the second factor process was finished by confirmation with a device.

        If this function returns true, you can safely use `TwoFactorLogin.finish` to finish the second factor authentication process.

        Returns:
            bool: True if the second factor was confirmed with a device.
        """

        request_url = f"https://{self.__edupage.subdomain}.edupage.org/login/twofactor?akcia=checkIfConfirmed"
        response = self.__edupage.session.post(request_url)

        data = response.json()
        if data.get("status") == "fail":
            return False
        elif data.get("status") != "ok":
            raise MissingDataException(
                f"Invalid response from edupage's server!: {str(data)}"
            )

        self.__code = data["data"]

        return True

    def resend_notifications(self):
        """Resends the confirmation notification to all devices."""

        request_url = f"https://{self.__edupage.subdomain}.edupage.org/login/twofactor?akcia=resendNotifs"
        response = self.__edupage.session.post(request_url)

        data = response.json()
        if data.get("status") != "ok":
            raise RequestError(f"Failed to resend notifications: {str(data)}")

    def __finish(self, code: str):
        request_url = (
            f"https://{self.__edupage.subdomain}.edupage.org/login/edubarLogin.php"
        )
        parameters = {
            "csrfauth": self.__csrf_token,
            "t2fasec": code,
            "2fNoSave": "y",
            "2fform": "1",
            "gu": self.__authentication_endpoint,
            "au": self.__authentication_token,
        }

        response = self.__edupage.session.post(request_url, parameters)

        if "window.location = gu;" in response.text:
            cookies = self.__edupage.session.cookies.get_dict(
                f"{self.__edupage.subdomain}.edupage.org"
            )

            Login(self.__edupage).reload_data(
                self.__edupage.subdomain, cookies["PHPSESSID"], self.__edupage.username
            )

            return

        raise SecondFactorFailedException(
            f"Second factor failed! (wrong/expired code? expired session?)"
        )

    def finish(self):
        """Finish the second factor authentication process.
        This function should be used when using a device to confirm the login. If you are using email 2fa codes, please use `TwoFactorLogin.finish_with_code`.

        Notes:
            - This function can only be used after `TwoFactorLogin.is_confirmed` returned `True`.
            - This function can raise `SecondFactorFailedException` if there is a big delay from calling `TwoFactorLogin.is_confirmed` (and getting `True` as a result) to calling `TwoFactorLogin.finish`.

        Raises:
            BadCredentialsException: You didn't call and get the `True` result from `TwoFactorLogin.is_confirmed` before calling this function.
            SecondFactorFailedException: The delay between calling `TwoFactorLogin.is_confirmed` and `TwoFactorLogin.finish` was too long, or there was another error with the second factor authentication confirmation process.
        """

        if self.__code is None:
            raise BadCredentialsException(
                "Not confirmed! (you can only call finish after `TwoFactorLogin.is_confirmed` has returned True)"
            )

        self.__finish(self.__code)

    def finish_with_code(self, code: str):
        """Finish the second factor authentication process.
        This function should be used when email 2fa codes are used to confirm the login. If you are using a device to confirm the login, please use `TwoFactorLogin.finish`.

        Args:
            code (str): The 2fa code from your email or from the mobile app.

        Raises:
            SecondFactorFailedException: An invalid 2fa code was provided.
        """
        self.__finish(code)


class Login(Module):
    class _InputValueParser(HTMLParser):
        def __init__(self, key: str):
            super().__init__()
            self._key = key
            self.value: Optional[str] = None

        def handle_starttag(self, tag, attrs):
            if tag.lower() != "input" or self.value is not None:
                return

            attr_map = {name.lower(): (value or "") for name, value in attrs}

            if attr_map.get("name") == self._key or attr_map.get("id") == self._key:
                found = attr_map.get("value")
                if found:
                    self.value = found

    @staticmethod
    def _extract_input_value(html: str, key: str) -> str:
        parser = Login._InputValueParser(key)
        parser.feed(html)

        if parser.value:
            return parser.value

        # Fallback for cases where the token is embedded in scripts or attributes
        fallback_patterns = [
            rf'{key}"?\s*value="([^"]+)"',
            rf'name=["\']{key}["\'][^>]*value=["\']([^"\']+)["\']',
            rf'id=["\']{key}["\'][^>]*value=["\']([^"\']+)["\']',
            rf'{key}\s*[:=]\s*"([^"]+)"',
            rf'{key}\s*[:=]\s*\'([^\']+)\'',
        ]

        for pattern in fallback_patterns:
            match = re.search(pattern, html, re.IGNORECASE)
            if match and match.group(1):
                return unescape(match.group(1))

        # Some schools now embed login props inside inline JSON objects (e.g. `var props = {...}`)
        object_pattern = re.compile(
            r"var\s+\w+\s*=\s*(\{.*?\});", re.IGNORECASE | re.DOTALL
        )
        for candidate in object_pattern.findall(html):
            try:
                payload = json.loads(candidate)
            except JSONDecodeError:
                continue

            value = payload.get(key)
            if value is not None and value != "":
                return str(value)

        raise MissingDataException(f"Could not find `{key}` input in HTML response")

    def __parse_login_data(self, data):
        json_string = (
            data.split("userhome(", 1)[1]
            .rsplit(");", 2)[0]
            .replace("\t", "")
            .replace("\n", "")
            .replace("\r", "")
        )

        self.edupage.data = json.loads(json_string)
        self.edupage.is_logged_in = True

        self.edupage.gsec_hash = data.split('ASC.gsechash="')[1].split('"')[0]

    def login(
        self, username: str, password: str, subdomain: str = "login1"
    ) -> Optional[TwoFactorLogin]:
        """Login to your school's Edupage account (optionally with 2 factor authentication).

        If you do not have 2 factor authentication set up, this function will return `None`.
        The login will still work and succeed.

        See the `Edupage.TwoFactorLogin` documentation or the examples for more details
        of the 2 factor authentication process.

        Args:
            username (str): Your username.
            password (str): Your password.
            subdomain (str): Subdomain of your school (https://{subdomain}.edupage.org).

        Returns:
            Optional[TwoFactorLogin]: The object that can be used to complete the second factor
                (or `None` — if the second factor is not set up)

        Raises:
            BadCredentialsException: Your credentials are invalid.
            CaptchaException: The login process failed because of a captcha.
            SecondFactorFailed: The second factor login timed out
                or there was another problem with the second factor.
        """

        request_url = f"https://{subdomain}.edupage.org/login/?cmd=MainLogin"

        response = self.edupage.session.get(request_url)
        data = response.content.decode()

        csrf_token: Optional[str] = None

        try:
            json_payload = json.loads(data)
            csrf_token = json_payload.get("csrftoken")
        except JSONDecodeError:
            pass

        if not csrf_token:
            match = re.search(r'"csrftoken"\s*:\s*"([^"]+)"', data)
            if match:
                csrf_token = match.group(1)

        if not csrf_token:
            try:
                csrf_token = self._extract_input_value(data, "csrfauth")
            except MissingDataException:
                csrf_token = None

        if not csrf_token:
            try:
                csrf_token = self._extract_input_value(data, "requestid")
            except MissingDataException as exc:
                raise MissingDataException(
                    "Could not locate `csrftoken`, `csrfauth`, or `requestid` in login response"
                ) from exc

        parameters = {
            "csrfauth": csrf_token,
            "username": username,
            "password": password,
        }

        request_url = f"https://{subdomain}.edupage.org/login/edubarLogin.php"

        response = self.edupage.session.post(request_url, parameters)

        if "cap=1" in response.url or "lerr=b43b43" in response.url:
            raise CaptchaException()

        if "bad=1" in response.url:
            raise BadCredentialsException()

        data = response.content.decode()

        if subdomain == "login1":
            subdomain = data.split("-->")[0].split(" ")[-1]

        self.edupage.subdomain = subdomain
        self.edupage.username = username

        if "twofactor" not in response.url:
            # 2FA not needed
            self.__parse_login_data(data)
            return

        request_url = (
            f"https://{self.edupage.subdomain}.edupage.org/login/twofactor?sn=1"
        )

        two_factor_response = self.edupage.session.get(request_url)

        data = two_factor_response.content.decode()

        csrf_token = self._extract_input_value(data, "csrfauth")
        authentication_token = self._extract_input_value(data, "au")
        authentication_endpoint = self._extract_input_value(data, "gu")

        return TwoFactorLogin(
            authentication_endpoint, authentication_token, csrf_token, self.edupage
        )

    def reload_data(self, subdomain: str, session_id: str, username: str):
        request_url = f"https://{subdomain}.edupage.org/user"

        self.edupage.session.cookies.set("PHPSESSID", session_id)

        response = self.edupage.session.get(request_url)

        try:
            self.__parse_login_data(response.content.decode())
            self.edupage.subdomain = subdomain
            self.edupage.username = username
        except (TypeError, JSONDecodeError) as e:
            raise BadCredentialsException(f"Invalid session id: {e}")

sveco86 avatar Nov 14 '25 16:11 sveco86

I think you are on the right track, but as I said there is no csrfauth in the page (https://gist.github.com/BerengarWLehr/03d82d6ddd2aa6a49ceab8262fe79f4a).

The page regularly sends updates via POST Content-Type application/x-www-form-urlencoded; charset=UTF-8, payload eqap=dz:BUCxCQAwDLrGG5y6hD4imQti7x8kXit6/4AD3gI= eqacs=2a3fadb795d449f2694bf0048c33d096b23d5a47 eqaz=1 to https://SCHOOLNAME.edupage.org/login/twofactor?akcia=checkIfConfirmed&akcia=checkIfConfirmed&eqav=1&maxEqav=7

When I enter a code, it sends via POST Content-Type application/x-www-form-urlencoded; charset=UTF-8, the payload eqap=dz:Zck7CsAgDMbx03iApoNThz7mLj1BENNFq/go9PYqQhC6hHy/f/DKY0AbFyE3AZCAMGpVPzGvbeuY2oC9XqDTXfhqrt+QyAXLYeKQcscnG9Pl/gmOIo8C eqacs=b3c07e34302c2ce4dff3fe44ac6959185e5a75eb eqaz=1

I tried to decode/debase64 that, but it just binary data.

BerengarWLehr avatar Nov 15 '25 07:11 BerengarWLehr

Update: So I found edubarUtils.js:256 where at least the payload is made a little bit more obvious.

var obj = {
    eqap: cs0,
    eqacs: sha1(cs0),
    eqaz: useEncryption ? '1' : '0',		
}

I can confirm, that eqacs is just sha1

$: echo -n 'dz:Zck7CsAgDMbx03iApoNThz7mLj1BENNFq/go9PYqQhC6hHy/f/DKY0AbFyE3AZCAMGpVPzGvbeuY2oC9XqDTXfhqrt+QyAXLYeKQcscnG9Pl/gmOIo8C' | sha1sum
b3c07e34302c2ce4dff3fe44ac6959185e5a75eb  -

so now I'm hunting for mysterious cs0

BerengarWLehr avatar Nov 15 '25 08:11 BerengarWLehr

This explains where cs0 is coming from:

Image

So its kind-a zip compressed version of `rpcparams={}

BerengarWLehr avatar Nov 15 '25 08:11 BerengarWLehr

By the way, if you actually enter a code (I testes "1234") cs = urlencode(rpcparams={"t2fasec":"1234","2fNoSave":"y","2fform":"1","tu":null,"gu":null,"au":null})

{
    "t2fasec":"1234",
    "2fNoSave":"y",
    "2fform":"1",
    "tu":null,
    "gu":null,
    "au":null
}

Not even the request ID is required to build this payload.

BerengarWLehr avatar Nov 15 '25 08:11 BerengarWLehr

It's a bit of a challenge to emulate bitewise equality to

var gz = new Zlib.RawDeflate(encoder.encode(cs))
var compressed = gz.compress()

from the zlib library at https://github.com/imaya/zlib.js/raw/master/bin/rawdeflate.min.js

But it's actually not necessary to have bitwise equality b/c the ZLib algorithm might produce different encoded results depending on internal parameters, but the python function is roundtrip equivalent to JavaScript (and probably EduPage's PHP function) so the following function should work

# Python equivalent generating payload for entering the code manually, no need to extract any parameters from the form
import json
from urllib.parse import urlencode
import zlib
import base64
import hashlib

def sha1(data: str) -> str:
    sha1_hash = hashlib.sha1()
    sha1_hash.update(data.encode('utf-8'))
    return sha1_hash.hexdigest()

def buildPayload(code: str) -> dict:
    data = { "rpcparams": json.dumps({
        "t2fasec":   code,
        "2fNoSave":   "y",
        "2fform":     "1",
        "tu":         None,
        "gu":         None,
        "au":         None
    }, separators=(',', ':'))}
    cmprs = zlib.compress(urlencode(data).encode('utf-8'), level=9, wbits=-15)
    cs0 = "dz:" + base64.b64encode(cmprs).decode('ascii')
    return {
        "eqap": cs0,
        "eqacs": sha1(cs0),
        "eqaz": '1'
    }

BerengarWLehr avatar Nov 15 '25 10:11 BerengarWLehr

Thanks @BerengarWLehr for detailed analysis. Here is new version of code to test.

What was changed:

  • Login now finds CSRF tokens whether Edupage renders hidden inputs or only exposes them in inline var props = {...} objects. _extract_input_value parses those JSON blobs, and the login flow falls back to requestid when csrfauth isn’t present.
  • Two-factor completion no longer depends on scraping csrfauth/gu/au. If those fields exist we keep the old flow; otherwise we build the encrypted payload (eqap/eqacs/eqaz) exactly as the browser does (raw-deflate of rpcparams, base64 with dz: prefix, SHA‑1 hash) and post it to the twofactor endpoint, then reload the session cookie. That allows manual code entry on the new login pages.

What is still open:

  • The 2FA polling/notification endpoints (is_confirmed, resend_notifications) currently POST without the new payload. If the your school requires the encrypted params for those calls too, we’ll need to extend _build_eq_payload usage there. Please let me know if this is the case

What to test:

  • I have retested the code and it still works for my school, should be back compatible
  • If polling still fails or requires eqap, please share your response details
import base64
import hashlib
import json
import re
import zlib
from dataclasses import dataclass
from html import unescape
from html.parser import HTMLParser
from json import JSONDecodeError
from typing import Optional
from urllib.parse import urlencode

from edupage_api.exceptions import (
    BadCredentialsException,
    CaptchaException,
    MissingDataException,
    RequestError,
    SecondFactorFailedException,
)
from edupage_api.module import EdupageModule, Module

def _build_eq_payload(params: dict) -> dict:
    rpc_payload = json.dumps(params, separators=(",", ":"))
    encoded = urlencode({"rpcparams": rpc_payload})
    compressor = zlib.compressobj(level=9, wbits=-15)
    compressed = compressor.compress(encoded.encode("utf-8")) + compressor.flush()
    cs0 = "dz:" + base64.b64encode(compressed).decode("ascii")
    sha1_hash = hashlib.sha1(cs0.encode("utf-8")).hexdigest()

    return {"eqap": cs0, "eqacs": sha1_hash, "eqaz": "1"}


@dataclass
class TwoFactorLogin:
    __authentication_endpoint: Optional[str]
    __authentication_token: Optional[str]
    __csrf_token: Optional[str]
    __edupage: EdupageModule

    __code: Optional[str] = None

    def is_confirmed(self):
        """Check if the second factor process was finished by confirmation with a device.

        If this function returns true, you can safely use `TwoFactorLogin.finish` to finish the second factor authentication process.

        Returns:
            bool: True if the second factor was confirmed with a device.
        """

        request_url = f"https://{self.__edupage.subdomain}.edupage.org/login/twofactor?akcia=checkIfConfirmed"
        response = self.__edupage.session.post(request_url)

        data = response.json()
        if data.get("status") == "fail":
            return False
        elif data.get("status") != "ok":
            raise MissingDataException(
                f"Invalid response from edupage's server!: {str(data)}"
            )

        self.__code = data["data"]

        return True

    def resend_notifications(self):
        """Resends the confirmation notification to all devices."""

        request_url = f"https://{self.__edupage.subdomain}.edupage.org/login/twofactor?akcia=resendNotifs"
        response = self.__edupage.session.post(request_url)

        data = response.json()
        if data.get("status") != "ok":
            raise RequestError(f"Failed to resend notifications: {str(data)}")

    def __finish(self, code: str):
        if (
            self.__csrf_token
            and self.__authentication_endpoint
            and self.__authentication_token
        ):
            self.__finish_with_legacy_parameters(code)
        else:
            self.__finish_with_eq_payload(code)

    def __finish_with_legacy_parameters(self, code: str):
        request_url = (
            f"https://{self.__edupage.subdomain}.edupage.org/login/edubarLogin.php"
        )
        parameters = {
            "csrfauth": self.__csrf_token,
            "t2fasec": code,
            "2fNoSave": "y",
            "2fform": "1",
            "gu": self.__authentication_endpoint,
            "au": self.__authentication_token,
        }

        response = self.__edupage.session.post(request_url, parameters)

        if "window.location = gu;" in response.text:
            self.__reload_after_twofactor()
            return

        raise SecondFactorFailedException(
            f"Second factor failed! (wrong/expired code? expired session?)"
        )

    def __finish_with_eq_payload(self, code: str):
        request_url = (
            f"https://{self.__edupage.subdomain}.edupage.org/login/twofactor?akcia=checkIfConfirmed"
        )
        rpc_params = {
            "t2fasec": code,
            "2fNoSave": "y",
            "2fform": "1",
            "tu": None,
            "gu": self.__authentication_endpoint,
            "au": self.__authentication_token,
        }
        payload = _build_eq_payload(rpc_params)
        response = self.__edupage.session.post(request_url, payload)

        try:
            data = response.json()
        except ValueError as exc:
            raise SecondFactorFailedException(
                f"Second factor failed! Invalid response: {response.text}"
            ) from exc

        if data.get("status") != "ok":
            raise SecondFactorFailedException(
                f"Second factor failed: {json.dumps(data)}"
            )

        self.__reload_after_twofactor()

    def __reload_after_twofactor(self):
        cookies = self.__edupage.session.cookies.get_dict(
            f"{self.__edupage.subdomain}.edupage.org"
        )
        session_id = cookies.get("PHPSESSID")

        if not session_id:
            raise SecondFactorFailedException("Second factor failed: missing PHPSESSID")

        Login(self.__edupage).reload_data(
            self.__edupage.subdomain, session_id, self.__edupage.username
        )

    def finish(self):
        """Finish the second factor authentication process.
        This function should be used when using a device to confirm the login. If you are using email 2fa codes, please use `TwoFactorLogin.finish_with_code`.

        Notes:
            - This function can only be used after `TwoFactorLogin.is_confirmed` returned `True`.
            - This function can raise `SecondFactorFailedException` if there is a big delay from calling `TwoFactorLogin.is_confirmed` (and getting `True` as a result) to calling `TwoFactorLogin.finish`.

        Raises:
            BadCredentialsException: You didn't call and get the `True` result from `TwoFactorLogin.is_confirmed` before calling this function.
            SecondFactorFailedException: The delay between calling `TwoFactorLogin.is_confirmed` and `TwoFactorLogin.finish` was too long, or there was another error with the second factor authentication confirmation process.
        """

        if self.__code is None:
            raise BadCredentialsException(
                "Not confirmed! (you can only call finish after `TwoFactorLogin.is_confirmed` has returned True)"
            )

        self.__finish(self.__code)

    def finish_with_code(self, code: str):
        """Finish the second factor authentication process.
        This function should be used when email 2fa codes are used to confirm the login. If you are using a device to confirm the login, please use `TwoFactorLogin.finish`.

        Args:
            code (str): The 2fa code from your email or from the mobile app.

        Raises:
            SecondFactorFailedException: An invalid 2fa code was provided.
        """
        self.__finish(code)


class Login(Module):
    class _InputValueParser(HTMLParser):
        def __init__(self, key: str):
            super().__init__()
            self._key = key
            self.value: Optional[str] = None

        def handle_starttag(self, tag, attrs):
            if tag.lower() != "input" or self.value is not None:
                return

            attr_map = {name.lower(): (value or "") for name, value in attrs}

            if attr_map.get("name") == self._key or attr_map.get("id") == self._key:
                found = attr_map.get("value")
                if found:
                    self.value = found

    @staticmethod
    def _extract_input_value(html: str, key: str) -> str:
        parser = Login._InputValueParser(key)
        parser.feed(html)

        if parser.value:
            return parser.value

        # Fallback for cases where the token is embedded in scripts or attributes
        fallback_patterns = [
            rf'{key}"?\s*value="([^"]+)"',
            rf'name=["\']{key}["\'][^>]*value=["\']([^"\']+)["\']',
            rf'id=["\']{key}["\'][^>]*value=["\']([^"\']+)["\']',
            rf'{key}\s*[:=]\s*"([^"]+)"',
            rf'{key}\s*[:=]\s*\'([^\']+)\'',
        ]

        for pattern in fallback_patterns:
            match = re.search(pattern, html, re.IGNORECASE)
            if match and match.group(1):
                return unescape(match.group(1))

        # Some schools now embed login props inside inline JSON objects (e.g. `var props = {...}`)
        object_pattern = re.compile(
            r"var\s+\w+\s*=\s*(\{.*?\});", re.IGNORECASE | re.DOTALL
        )
        for candidate in object_pattern.findall(html):
            try:
                payload = json.loads(candidate)
            except JSONDecodeError:
                continue

            value = payload.get(key)
            if value is not None:
                return str(value)

        raise MissingDataException(f"Could not find `{key}` input in HTML response")

    def __parse_login_data(self, data):
        json_string = (
            data.split("userhome(", 1)[1]
            .rsplit(");", 2)[0]
            .replace("\t", "")
            .replace("\n", "")
            .replace("\r", "")
        )

        self.edupage.data = json.loads(json_string)
        self.edupage.is_logged_in = True

        self.edupage.gsec_hash = data.split('ASC.gsechash="')[1].split('"')[0]

    def login(
        self, username: str, password: str, subdomain: str = "login1"
    ) -> Optional[TwoFactorLogin]:
        """Login to your school's Edupage account (optionally with 2 factor authentication).

        If you do not have 2 factor authentication set up, this function will return `None`.
        The login will still work and succeed.

        See the `Edupage.TwoFactorLogin` documentation or the examples for more details
        of the 2 factor authentication process.

        Args:
            username (str): Your username.
            password (str): Your password.
            subdomain (str): Subdomain of your school (https://{subdomain}.edupage.org).

        Returns:
            Optional[TwoFactorLogin]: The object that can be used to complete the second factor
                (or `None` — if the second factor is not set up)

        Raises:
            BadCredentialsException: Your credentials are invalid.
            CaptchaException: The login process failed because of a captcha.
            SecondFactorFailed: The second factor login timed out
                or there was another problem with the second factor.
        """

        request_url = f"https://{subdomain}.edupage.org/login/?cmd=MainLogin"

        response = self.edupage.session.get(request_url)
        data = response.content.decode()

        csrf_token: Optional[str] = None

        try:
            json_payload = json.loads(data)
            csrf_token = json_payload.get("csrftoken")
        except JSONDecodeError:
            pass

        if not csrf_token:
            match = re.search(r'"csrftoken"\s*:\s*"([^"]+)"', data)
            if match:
                csrf_token = match.group(1)

        if not csrf_token:
            try:
                csrf_token = self._extract_input_value(data, "csrfauth")
            except MissingDataException:
                csrf_token = None

        if not csrf_token:
            try:
                csrf_token = self._extract_input_value(data, "requestid")
            except MissingDataException as exc:
                raise MissingDataException(
                    "Could not locate `csrftoken`, `csrfauth`, or `requestid` in login response"
                ) from exc

        parameters = {
            "csrfauth": csrf_token,
            "username": username,
            "password": password,
        }

        request_url = f"https://{subdomain}.edupage.org/login/edubarLogin.php"

        response = self.edupage.session.post(request_url, parameters)

        if "cap=1" in response.url or "lerr=b43b43" in response.url:
            raise CaptchaException()

        if "bad=1" in response.url:
            raise BadCredentialsException()

        data = response.content.decode()

        if subdomain == "login1":
            subdomain = data.split("-->")[0].split(" ")[-1]

        self.edupage.subdomain = subdomain
        self.edupage.username = username

        if "twofactor" not in response.url:
            # 2FA not needed
            self.__parse_login_data(data)
            return

        request_url = (
            f"https://{self.edupage.subdomain}.edupage.org/login/twofactor?sn=1"
        )

        two_factor_response = self.edupage.session.get(request_url)

        data = two_factor_response.content.decode()

        def _optional_input(key: str) -> Optional[str]:
            try:
                return self._extract_input_value(data, key)
            except MissingDataException:
                return None

        csrf_token = _optional_input("csrfauth")
        authentication_token = _optional_input("au")
        authentication_endpoint = _optional_input("gu")

        return TwoFactorLogin(
            authentication_endpoint, authentication_token, csrf_token, self.edupage
        )

    def reload_data(self, subdomain: str, session_id: str, username: str):
        request_url = f"https://{subdomain}.edupage.org/user"

        self.edupage.session.cookies.set("PHPSESSID", session_id)

        response = self.edupage.session.get(request_url)

        try:
            self.__parse_login_data(response.content.decode())
            self.edupage.subdomain = subdomain
            self.edupage.username = username
        except (TypeError, JSONDecodeError) as e:
            raise BadCredentialsException(f"Invalid session id: {e}")

sveco86 avatar Nov 15 '25 21:11 sveco86

A few little changes and still only with entering the code. Just accepting 2FA in the App does currently not work:

The correct URL for sending 2FA code L110-112:

        request_url = (
            f"https://{self.__edupage.subdomain}.edupage.org/login/?cmd=MainLogin&akcia=login"
        )

A prefix control added in L124:

        if response.text[:4] != "eqz:":
            raise SecondFactorFailedException(
                f"Second factor failed! Invalid prefix: {response.text}"
            )

Data must be base64 decoded before parsing json:

            data = json.loads(base64.b64decode(response.text[4:]).decode("utf8"))

For some reason data.status is now "OK" and not "ok" and if you leave the code it will still send status: "OK" but also need2fa: 1:

        if data.get("status").lower() != "ok" or data.get("need2fa") is not None:

So this is my adjusted function

    def __finish_with_eq_payload(self, code: str):
        request_url = (
            f"https://{self.__edupage.subdomain}.edupage.org/login/?cmd=MainLogin&akcia=login"
        )
        rpc_params = {
            "t2fasec": code,
            "2fNoSave": "y",
            "2fform": "1",
            "tu": None,
            "gu": self.__authentication_endpoint,
            "au": self.__authentication_token,
        }
        payload = _build_eq_payload(rpc_params)
        response = self.__edupage.session.post(request_url, payload)

        if response.text[:4] != "eqz:":
            raise SecondFactorFailedException(
                f"Second factor failed! Invalid prefix: {response.text}"
            )

        try:
            data = json.loads(base64.b64decode(response.text[4:]).decode("utf8"))
            print(data)
        except ValueError as exc:
            raise SecondFactorFailedException(
                f"Second factor failed! Invalid response: {response.text}"
            ) from exc

        if data.get("status").lower() != "ok" or data.get("need2fa") is not None:
            raise SecondFactorFailedException(
                f"Second factor failed: {json.dumps(data)}"
            )

Will report back when logging in via App alone works

Update: I just checked and logging in without code doesn't work in the official flow for me neither.

BerengarWLehr avatar Nov 16 '25 08:11 BerengarWLehr