diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index e0a3a73..d98f068 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -22,9 +22,58 @@ Usage Client initialisation ^^^^^^^^^^^^^^^^^^^^^ -To create a new client you need to create an instance and pass your pronote -address, username and password. This will initialise the connection and log the -user in. You can check if the client is logged with the logged_in attribute. +The client can be created in multiple ways. They differ only by login method. + +Logging in with QR code (recommended) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. (one time setup) Obtain credentials by using ``python3 -m pronotepy.create_login``. You can either: + + 1. Generate a QR code in PRONOTE and scan its contents so that you can paste them into the script, or + + 2. log in using username and password. You may have to find an appropriate + ENT function in :doc:`api/ent`. (see logging in with username and + password below) + + The script uses :meth:`.ClientBase.qrcode_login` internally. You can generate + new QR codes using :meth:`.ClientBase.request_qr_code_data`. + +2. Create a :class:`.Client` using :meth:`.ClientBase.token_login`, passing in the credentials generated in the first step. + + .. code-block:: python + + import pronotepy + + client = pronotepy.Client.token_login( + "https://demo.index-education.net/pronote/mobile.eleve.html?login=true", + "demonstration", + "SUPER_LONG_TOKEN_ABCDEFG", + "RandomGeneratedUuid", + ) + # save new credentials + credentials = { + "url": client.pronote_url, + "username": client.username, + "password": client.password, + "uuid": client.uuid, + } + ... + + .. warning:: Save your new credentials somewhere safe. PRONOTE generates a + new password token after each login. + + +Logging in with username and password +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +You can also create a new client by passing in your username and password. This +needs to go through your ENT everytime you login. Consider logging in using the +QR code method. + +Use an ENT function from :doc:`api/ent` if you are logging in through an ENT. + +.. note:: The URL passed into the client must be a direct one to your pronote + instance. It usually ends with ``eleve.html``, ``parent.html``, or something + similar. Do not include any query parameters. .. code-block:: python @@ -39,9 +88,6 @@ user in. You can check if the client is logged with the logged_in attribute. if not client.logged_in: exit(1) # the client has failed to log in -.. note:: Make sure that the url is directly pointing to the html file (usually schools have their pronote - hosted by index-education so the url will be similar to the one in the example above). - Homework ^^^^^^^^ To access the user's homework use the :meth:`.Client.homework` method. diff --git a/pronotepy/clients.py b/pronotepy/clients.py index 0e4c07d..526a34a 100644 --- a/pronotepy/clients.py +++ b/pronotepy/clients.py @@ -52,7 +52,8 @@ def __init__( username: str = "", password: str = "", ent: Optional[Callable[[str, str], "RequestsCookieJar"]] = None, - qr_code: bool = False, + mode: str = "normal", + uuid: str = "", ) -> None: log.info("INIT") # start communication session @@ -68,8 +69,10 @@ def __init__( else: cookies = None - self.uuid = str(uuid4()) - self.mobile = qr_code + if mode != "normal" and not uuid: + raise PronoteAPIError("UUID must not be empty") + self.uuid = uuid + self.login_mode = mode self.username = username self.password = password @@ -102,12 +105,17 @@ def __init__( self._expired = False @classmethod - def qrcode_login(cls: Type[T], qr_code: dict, pin: str) -> T: + def qrcode_login(cls: Type[T], qr_code: dict, pin: str, uuid: str) -> T: """Login with QR code + The created client instance will have its username and password + attributes set to the credentials for the next login using + :meth:`.token_login`. + Args: - qr_code (dict): JSON store in the QR code + qr_code (dict): JSON contained in the QR code. Must have ``login``, ``jeton`` and ``url`` keys. pin (str): 4-digit confirmation code created during QR code setup + uuid (str): Unique ID for your application. Must not change between logins. """ encryption = _Encryption() encryption.aes_set_key(pin.encode()) @@ -119,15 +127,31 @@ def qrcode_login(cls: Type[T], qr_code: dict, pin: str) -> T: login = encryption.aes_decrypt(short_token).decode() jeton = encryption.aes_decrypt(long_token).decode() except CryptoError as ex: - ex.args += ( - "exception happened during login -> probably the confirmation code is not valid", - ) - raise + raise QRCodeDecryptError("invalid confirmation code") from ex # add ?login=true at the end of the url url = re.sub(r"(\?.*)|( *)$", "?login=true", qr_code["url"], 0) - return cls(url, login, jeton, qr_code=True) + return cls(url, login, jeton, mode="qr_code", uuid=uuid) + + @classmethod + def token_login( + cls: Type[T], pronote_url: str, username: str, password: str, uuid: str + ) -> T: + """ + Login with a password token. Used for logins after :meth:`.qrcode_login`. + + The created client instance will have its username and password + attributes set to the credentials for the next login using + :meth:`.token_login`. + + Args: + pronote_url (str): URL of the server + username (str) + password (str): Password token received from the previous login + uuid (str): Unique ID for your application. Must not change between logins. + """ + return cls(pronote_url, username, password, mode="token", uuid=uuid) def _login(self) -> bool: """Logs in the user. @@ -146,9 +170,12 @@ def _login(self) -> bool: "pourENT": True if self.ent else False, "enConnexionAuto": False, "demandeConnexionAuto": False, - "demandeConnexionAppliMobile": self.mobile, - "demandeConnexionAppliMobileJeton": self.mobile, - "uuidAppliMobile": self.uuid if self.mobile else "", + "demandeConnexionAppliMobile": self.login_mode == "qr_code", + "demandeConnexionAppliMobileJeton": self.login_mode == "qr_code", + "enConnexionAppliMobile": self.login_mode == "token", + "uuidAppliMobile": self.uuid + if self.login_mode in ("qr_code", "token") + else "", "loginTokenSAV": "", } idr = self.post("Identification", data=ident_json) @@ -164,15 +191,6 @@ def _login(self) -> bool: if self.ent: motdepasse = SHA256.new(str(self.password).encode()).hexdigest().upper() e.aes_set_key(motdepasse.encode()) - elif self.mobile: - u = self.username - p = self.password - if idr["donneesSec"]["donnees"]["modeCompLog"]: - u = u.lower() - if idr["donneesSec"]["donnees"]["modeCompMdp"]: - p = p.lower() - motdepasse = SHA256.new(p.encode()).hexdigest().upper() - e.aes_set_key((u + motdepasse).encode()) else: u = self.username p = self.password @@ -180,7 +198,7 @@ def _login(self) -> bool: u = u.lower() if idr["donneesSec"]["donnees"]["modeCompMdp"]: p = p.lower() - alea = idr["donneesSec"]["donnees"]["alea"] + alea = idr["donneesSec"]["donnees"].get("alea", "") motdepasse = SHA256.new((alea + p).encode()).hexdigest().upper() e.aes_set_key((u + motdepasse).encode()) @@ -190,7 +208,7 @@ def _login(self) -> bool: dec_no_alea = _enleverAlea(dec.decode()) ch = e.aes_encrypt(dec_no_alea.encode()).hex() except CryptoError as ex: - if self.mobile: + if self.login_mode == "qr_code": ex.args += ( "exception happened during login -> probably the qr code has expired (qr code is valid during 10 minutes)", ) @@ -212,9 +230,9 @@ def _login(self) -> bool: self.encryption.aes_key = e.aes_key log.info(f"successfully logged in as {self.username}") - if self.mobile and auth_response["donneesSec"]["donnees"].get( - "jetonConnexionAppliMobile" - ): + if self.login_mode in ("qr_code", "token") and auth_response["donneesSec"][ + "donnees" + ].get("jetonConnexionAppliMobile"): self.password = auth_response["donneesSec"]["donnees"][ "jetonConnexionAppliMobile" ] @@ -332,6 +350,22 @@ def post( return self.communication.post(function_name, post_data) + def request_qr_code_data(self, pin: str) -> dict: + """ + Requests data for a new login QR code. This data can be then used with :meth:`.qrcode_login`. + + Args: + pin (str): Four digit pin to use for the QR code + """ + req = self.post("JetonAppliMobile", 7, {"code": pin}) + return { + # ugly way to add the mobile prefix to the url + "url": re.sub( + r"/(?:mobile.){,1}(\w+).html$", r"/mobile.\1.html", self.pronote_url + ), + **req["donneesSec"]["donnees"], + } + class Client(ClientBase): """ @@ -350,9 +384,10 @@ def __init__( username: str = "", password: str = "", ent: Optional[Callable] = None, - qr_code: bool = False, + mode: str = "normal", + uuid: str = "", ) -> None: - super().__init__(pronote_url, username, password, ent, qr_code) + super().__init__(pronote_url, username, password, ent, mode, uuid) def lessons( self, @@ -654,9 +689,10 @@ def __init__( username: str = "", password: str = "", ent: Optional[Callable] = None, - qr_code: bool = False, + mode: str = "normal", + uuid: str = "", ) -> None: - super().__init__(pronote_url, username, password, ent, qr_code) + super().__init__(pronote_url, username, password, ent, mode, uuid) self.children: List[dataClasses.ClientInfo] = [] for c in self.parametres_utilisateur["donneesSec"]["donnees"]["ressource"][ @@ -751,9 +787,10 @@ def __init__( username: str = "", password: str = "", ent: Optional[Callable] = None, - qr_code: bool = False, + mode: str = "normal", + uuid: str = "", ) -> None: - super().__init__(pronote_url, username, password, ent, qr_code) + super().__init__(pronote_url, username, password, ent, mode, uuid) self.classes = [ dataClasses.StudentClass(self, json) for json in self.parametres_utilisateur["donneesSec"]["donnees"][ diff --git a/pronotepy/create_login.py b/pronotepy/create_login.py new file mode 100644 index 0000000..31b4459 --- /dev/null +++ b/pronotepy/create_login.py @@ -0,0 +1,58 @@ +import getpass +from . import Client, ent as ent_module +import random +import secrets +import json + + +def main() -> int: + raw_json = input("JSON QR code contents (leave blank for user/pass login): ") + + if raw_json: + qr_code = json.loads(raw_json) + pin = input("QR code PIN: ") + else: + print() + print("Please input the full url ending with eleve/parent.html") + url = input("URL of your pronote instance: ") + + ent_name = input("Your ENT (name of an ENT function, or leave empty): ") + + ent = getattr(ent_module, ent_name, None) + if ent_name and not ent: + print( + f'Could not find ENT "{ent_name}". Pick one from https://pronotepy.rtfd.io/en/stable/api/ent.html' + ) + return 1 + + username = input("Your login username: ") + password = getpass.getpass("Your login password: ") + + client = Client(url, username, password, ent) + + # lol + pin = "".join([str(random.choice(tuple(range(1, 10)))) for _ in range(4)]) + + qr_code = client.request_qr_code_data(pin) + + uuid = input( + "Application UUID, preferably something random (leave blank for random): " + ) or secrets.token_hex(8) + + client = Client.qrcode_login(qr_code, pin, uuid) + + print("\nCredentials:\n") + print("Server URL:", client.pronote_url) + print("Username:", client.username) + print("Password:", client.password) + print("UUID:", client.uuid) + print() + print( + "Log in using Client.token_login and do not forget to keep the new client.password after each login" + ) + + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/pronotepy/exceptions.py b/pronotepy/exceptions.py index 6fdc471..c219f91 100644 --- a/pronotepy/exceptions.py +++ b/pronotepy/exceptions.py @@ -12,6 +12,7 @@ "ENTLoginError", "UnsupportedOperation", "DiscussionClosed", + "QRCodeDecryptError", ) @@ -37,6 +38,12 @@ class CryptoError(PronoteAPIError): pass +class QRCodeDecryptError(CryptoError): + """Raised when the QR code cannot be decrypted.""" + + pass + + class ExpiredObject(PronoteAPIError): """Raised when pronote returns error 22. (unknown object reference)"""