Skip to content

Commit

Permalink
Merge pull request #258 from bain3/qrlogin
Browse files Browse the repository at this point in the history
Implement logins through QR codes
  • Loading branch information
bain3 authored Sep 4, 2023
2 parents a7f5fed + bba4629 commit 0022ee6
Show file tree
Hide file tree
Showing 4 changed files with 187 additions and 39 deletions.
58 changes: 52 additions & 6 deletions docs/source/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
103 changes: 70 additions & 33 deletions pronotepy/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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())
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -164,23 +191,14 @@ 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
if idr["donneesSec"]["donnees"]["modeCompLog"]:
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())

Expand All @@ -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)",
)
Expand All @@ -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"
]
Expand Down Expand Up @@ -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):
"""
Expand All @@ -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,
Expand Down Expand Up @@ -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"][
Expand Down Expand Up @@ -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"][
Expand Down
58 changes: 58 additions & 0 deletions pronotepy/create_login.py
Original file line number Diff line number Diff line change
@@ -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())
7 changes: 7 additions & 0 deletions pronotepy/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"ENTLoginError",
"UnsupportedOperation",
"DiscussionClosed",
"QRCodeDecryptError",
)


Expand All @@ -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)"""

Expand Down

0 comments on commit 0022ee6

Please sign in to comment.