aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas "Cakeisalie5" Touhey <thomas@touhey.fr>2018-10-28 00:44:47 +0200
committerThomas "Cakeisalie5" Touhey <thomas@touhey.fr>2018-10-28 00:44:47 +0200
commitfdb01348fccc751627e10adad6e6bab111683cde (patch)
treef07492679667f32594b564740a79e43d2589a760
parentdcf9be0eabe03c5828add50093a6eddba77e5334 (diff)
Working password reset and adherent code retrieval.
-rw-r--r--docs/index.rst2
-rw-r--r--docs/intranet/auth-mailids2.pngbin0 -> 56614 bytes
-rw-r--r--docs/intranet/auth-mailids3.pngbin0 -> 57368 bytes
-rw-r--r--docs/intranet/auth.rst54
-rwxr-xr-xsgdfi/__init__.py2
-rwxr-xr-xsgdfi/_decode.py76
-rwxr-xr-xsgdfi/_intranet.py185
-rwxr-xr-xsgdfi/_manager.py9
-rwxr-xr-xsgdfi/_util.py52
9 files changed, 326 insertions, 54 deletions
diff --git a/docs/index.rst b/docs/index.rst
index e5b8730..10bd90b 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -14,7 +14,7 @@ provide machine access to the websites in this digital environment.
usage/index
environment
- todo
+ status
.. _SGDF: https://www.sgdf.fr/
.. _Scoutisme Français: https://www.scoutisme-francais.fr/
diff --git a/docs/intranet/auth-mailids2.png b/docs/intranet/auth-mailids2.png
new file mode 100644
index 0000000..3535dfd
--- /dev/null
+++ b/docs/intranet/auth-mailids2.png
Binary files differ
diff --git a/docs/intranet/auth-mailids3.png b/docs/intranet/auth-mailids3.png
new file mode 100644
index 0000000..95bc035
--- /dev/null
+++ b/docs/intranet/auth-mailids3.png
Binary files differ
diff --git a/docs/intranet/auth.rst b/docs/intranet/auth.rst
index d883888..20be2b2 100644
--- a/docs/intranet/auth.rst
+++ b/docs/intranet/auth.rst
@@ -7,15 +7,22 @@ other services as a central authentication service.
Once the registration process for a person in charge is complete, this person
will receive an e-mail from `adherents@sgdf.fr <mailto:adherents@sgdf.fr>`_
with their adherent code, which serves as a login name, and a default
-password:
+password (:download:`sample <auth-mailids.png>`).
-.. image:: auth-mailids.png
+The code can be retrieved using the first name, common name and birth date
+of an adherent, which will send a mail to the person containing their
+adherent code, without resetting their password
+(:download:`sample <auth-mailids3.png>`).
+
+The password can be changed using the adherent code, in case it has been
+forgotten; this will result in an instant password reset and a sent mail
+containing the new password (:download:`sample <auth-mailids3.png>`).
These credentials can be used for the intranet and the approved external
services using it to authenticate and identify users.
-Internal authentication
------------------------
+Logging in (internal)
+---------------------
Authentification goes through the ``/Default.aspx`` page as a normal form.
The following arguments are taken:
@@ -36,23 +43,38 @@ otherwise the same page is loaded with a warning, amongst:
The password is invalid.
``Le compte associé à l'identifiant 'XXXXXXXXX' ne donne pas le droit d'utiliser cette application``
- The given identifier doesn't allow to login (e.g. ``160000000``).
+ The given identifier isn't allowed to login (e.g. ``160000000``).
-External authentication
------------------------
+Requiring a new password
+------------------------
-Authentication can be provided to approved services through a Web Service,
-`Authentification.asmx`_. Example services using this external authentication
-service are:
+To ask for a new password, one shall use the form on
+``/securite/OubliMotDePasse.aspx`` while sending the following arguments:
-- `<http://decouverte.sgdf.fr/>`_:
+``ctl00$MainContent$_tbIdentifiant``
+ The identifier.
- .. image:: auth-decouverte.png
-- `<https://petitions.sgdf.fr/>`_:
+If there was a problem with the request, the same page is loaded with a
+``ctl00__erreur__lblErreur`` element containing the error message, amongst:
- .. image:: auth-petitions.png
-- `<https://valorise-toi.sgdf.fr/>`_:
+``Identifiant invalide``
+ The given identifier is invalid.
+
+``Le compte associé à l'identifiant 'XXXXXXXXX' ne donne pas le droit d'utiliser cette application``
+ The given identifier isn't allowed to login (e.g. ``160000000``).
+
+If the request has successfully been executed, the page doesn't contain a
+form (``ctl00_MainContent__tableFormulaire``) nor an error message.
+
+Logging in (external)
+---------------------
+
+Authentication can be provided to approved services through a Web Service,
+`Authentification.asmx`_. Example services using this external authentication
+service are:
- .. image:: auth-valorise.png
+- `<http://decouverte.sgdf.fr/>`_ (:download:`sample <auth-decouverte.png>`).
+- `<https://petitions.sgdf.fr/>`_ (:download:`sample <auth-petitions.png>`).
+- `<https://valorise-toi.sgdf.fr/>`_ (:download:`sample <auth-valorise.png>`).
.. _Authentification.asmx: https://intranet.sgdf.fr/Specialisation/Sgdf/WebServices/Authentification.asmx
diff --git a/sgdfi/__init__.py b/sgdfi/__init__.py
index 0f59164..465d2df 100755
--- a/sgdfi/__init__.py
+++ b/sgdfi/__init__.py
@@ -10,5 +10,7 @@ from ._manager import Manager
from ._repr import Structure, Adherent, RallyRegistration, Camp, Place, \
Operation, OperationType, Function, Event, StructureType, \
StructureStatus, AllocationsRegime
+from ._util import InvalidCredentialsError, InvalidUserError, \
+ InvalidPasswordError, UnauthorizedAccountError
# End of file.
diff --git a/sgdfi/_decode.py b/sgdfi/_decode.py
index a097a99..3a80b44 100755
--- a/sgdfi/_decode.py
+++ b/sgdfi/_decode.py
@@ -130,7 +130,12 @@ class Decoder:
`content`: the content as a stream, bytes or text object.
`type`: the MIME type.
`hint`: the hint, including the context (e.g.
- `intranet_place`). """
+ `intranet_place`).
+
+ Special hints:
+ - `ignore`: ignore the content.
+ - `raw`: return the raw content.
+ - `sic`: return the type decoded content. """
if hint == 'ignore':
return None
@@ -170,13 +175,17 @@ class Decoder:
if not hint:
raise ValueError(f"Missing HTML hint")
- try:
- func = getattr(self, f"_decode_html_{hint}")
- except AttributeError:
- msg = f"Unknown HTML hint: {repr(hint)}"
- raise ValueError(msg) from None
+ elif hint != 'sic':
+ try:
+ func = getattr(self, f"_decode_html_{hint}")
+ except AttributeError:
+ msg = f"Unknown HTML hint: {repr(hint)}"
+ raise ValueError(msg) from None
content = _BeautifulSoup(inp, 'lxml')
+ if hint == 'sic':
+ return content
+
result = func(content)
elif type == 'application/x-microsoft-ajax':
# The input might be a stream.
@@ -207,6 +216,9 @@ class Decoder:
resp.append(_AjaxField(code, name, attrib, text))
+ if hint == 'sic':
+ return resp
+
# The answer's payload usually is in the second entry.
# The code may allow to determinate what it corresponds to,
# but a lot of codes can be used depending on the actions
@@ -253,11 +265,12 @@ class Decoder:
# Try to find out for the hint.
- try:
- assert hint != None
- func = getattr(self, f"_decode_csv_{hint}")
- except (AssertionError, AttributeError):
- raise ValueError(f"Invalid CSV hint: {hint}") from None
+ if hint != 'sic':
+ try:
+ assert hint != None
+ func = getattr(self, f"_decode_csv_{hint}")
+ except (AssertionError, AttributeError):
+ raise ValueError(f"Invalid CSV hint: {hint}") from None
# Decode the file through the CSV reader, and send it to the
# function.
@@ -265,6 +278,9 @@ class Decoder:
reader = _csvreader(content, delimiter = ';')
resp = [row for row in reader]
+ if hint == 'sic':
+ return resp
+
return func(resp)
elif type == 'application/vnd.ms-excel': # XLS document.
# The input might be of different type, we want a text stream.
@@ -278,11 +294,12 @@ class Decoder:
# Get the hint.
- try:
- assert hint != None
- func = getattr(self, f"_decode_xls_{hint}")
- except (AssertionError, AttributeError):
- raise ValueError(f"Invalid XLS hint: {hint}") from None
+ if hint != 'sic':
+ try:
+ assert hint != None
+ func = getattr(self, f"_decode_xls_{hint}")
+ except (AssertionError, AttributeError):
+ raise ValueError(f"Invalid XLS hint: {hint}") from None
# Read the entries through pandas and call the function.
@@ -300,18 +317,27 @@ class Decoder:
break
resp = [e for e in entries(content)]
+ if hint == 'sic':
+ return resp
+
return func(resp)
elif type == 'text/xml':
# We ought to read the XML document through BeautifulSoup.
+ # If the first element is a '<string>', then we suppose the
+ # content is JSON.
content = _BeautifulSoup(inp, 'lxml')
+ if next(content.body.children).name == 'string':
+ content = _jsonloads(next(content.body.children).text)
+
+ if hint == 'sic':
+ return hint
# And here we manage this manually.
# TODO: there is a more elegant solution.
if hint == 'intranet_functions':
- data = _jsonloads(tree.getroot().text)
- return self.__decode_json_intranet_functions(data)
+ return self.__decode_json_intranet_functions(content)
else:
raise ValueError("Unknown XML hint: {repr(hint)}")
elif type == 'application/json':
@@ -329,11 +355,12 @@ class Decoder:
# Try to find out for the hint.
- try:
- assert hint != None
- func = getattr(self, f"_decode_json_{hint}")
- except (AssertionError, AttributeError):
- raise ValueError(f"Invalid JSON hint: {hint}") from None
+ if hint != 'sic':
+ try:
+ assert hint != None
+ func = getattr(self, f"_decode_json_{hint}")
+ except (AssertionError, AttributeError):
+ raise ValueError(f"Invalid JSON hint: {hint}") from None
# Then load using the standard json module.
# If it is a {'d': '…'} payload, it is a WebService answer and
@@ -348,6 +375,9 @@ class Decoder:
except:
pass
+ if hint == 'sic':
+ return data
+
return func(data)
else:
raise ValueError(f"unknown type: {repr(type)}")
diff --git a/sgdfi/_intranet.py b/sgdfi/_intranet.py
index a217acd..8910868 100755
--- a/sgdfi/_intranet.py
+++ b/sgdfi/_intranet.py
@@ -9,14 +9,22 @@ from os import linesep as _linesep
from sys import stderr as _stderr
from time import strftime as _strftime, localtime as _localtime, \
clock_gettime as _getclocktime, CLOCK_MONOTONIC as _MONOCLOCK
+from datetime import date as _date, datetime as _datetime
from io import StringIO as _StringIO
from itertools import count as _count
+from base64 import b64encode as _b64encode
+from urllib.parse import urlencode as _urlencode, urlparse as _urlparse, \
+ parse_qs as _parse_qs
from json import dumps as _jsondumps
from requests import Session as _Session
from bs4 import BeautifulSoup as _BeautifulSoup
from ._repr import IID as _IID
+from ._util import InvalidCredentialsError as _InvalidCredentialsError, \
+ InvalidUserError as _InvalidUserError, \
+ InvalidPasswordError as _InvalidPasswordError, \
+ UnauthorizedAccountError as _UnauthorizedAccountError
__all__ = ["AnonymousIntranetSession", "IntranetSession"]
@@ -26,7 +34,7 @@ class RedirectError(Exception):
""" The response is a redirection. """
def __init__(self, location):
- super().__init__(f"Was redirected to {repr(location)}")
+ super().__init__(f"Was redirected to {repr(location)}.")
self.__location = location
@property
@@ -35,6 +43,25 @@ class RedirectError(Exception):
return self.__location
+class BadRequestError(RedirectError):
+ """ The response is a redirection to the bad request page. """
+
+ def __init__(self, location):
+ path = '/Specialisation/sgdf/erreurs/Erreur.aspx?' \
+ + _urlencode({'aspxerrorpath': location})
+ super().__init__(location)
+
+ self.__loc = location
+
+ def __str__(self):
+ return f"Erroneous request to {repr(self.__loc)}."
+
+ @property
+ def path(self):
+ """ The error path. """
+
+ return self.__loc
+
# ---
# Définition des objets principaux.
# ---
@@ -42,11 +69,13 @@ class RedirectError(Exception):
class AnonymousIntranetSession:
""" Class for interacting with the intranet while not logged in. """
- def __init__(self, manager, base = 'https://intranet.sgdf.fr'):
+ def __init__(self, manager, base = 'https://intranet.sgdf.fr',
+ debug = False):
self.__mgr = manager
self.__base = base
self.__session = _Session()
self.__oldsess = None
+ self.__dbg = bool(debug or manager._debug)
def __repr__(self):
return f"{self.__class__.__name__}()"
@@ -56,7 +85,8 @@ class AnonymousIntranetSession:
f"I:anon]"
def __log(self, *msg):
- _stderr.write(f"{self._log_prefix()} {' '.join(msg)}{_linesep}")
+ if self.__dbg:
+ _stderr.write(f"{self._log_prefix()} {' '.join(msg)}{_linesep}")
# ---
# Base utilities.
@@ -109,6 +139,16 @@ class AnonymousIntranetSession:
loc = r.headers['Location']
self.__log(f"({r.status_code}) {loc}")
+ try:
+ p = _urlparse(loc)
+ assert p.path.casefold() in ('/erreurs/erreur.aspx',
+ '/specialisation/sgdf/erreurs/erreur.aspx')
+
+ path = _parse_qs(p.query)['aspxerrorpath'][0]
+ raise BadRequestError(path) from None
+ except (AssertionError, KeyError, IndexError):
+ pass
+
raise RedirectError(loc)
# Check if we ought to ignore the thing.
@@ -240,6 +280,13 @@ class AnonymousIntranetSession:
else:
pkw['data'] = payload
+ if self.__dbg:
+ self.__log("Page POST arguments:")
+ it = map(lambda x: (x, payload[x]), sorted(payload.keys()))
+ for key, value in it:
+ self.__log(f"{repr(key)}")
+ self.__log(f"= {repr(value)}")
+
tm = _monotime()
r = self.__session.post(self.__base + path, headers = headers,
allow_redirects = False, **pkw)
@@ -247,12 +294,109 @@ class AnonymousIntranetSession:
return ret(r)
+ # ---
+ # Session-related.
+ # ---
+
+ def request_password(self, code):
+ """ Request a password for an account. """
+
+ if type(code) != str:
+ raise ValueError("Code must be a string.")
+
+ # Make the request.
+
+ path = '/securite/OubliMotDePasse.aspx'
+ result = self.get_page(path, args = {
+ 'ctl00': {
+ 'MainContent': {
+ '_tbIdentifiant': code}}},
+ method = self.METHOD_FORM,
+ hint = 'sic')
+
+ # Check if there is an error.
+
+ espan = result.find(id = 'ctl00__erreur__lblErreur')
+ if espan is not None:
+ error = espan.text.strip()
+ if error.startswith("Le compte associé à l'identifiant '") \
+ and error.endswith("' ne donne pas le droit d'utiliser cette " \
+ "application"):
+ raise _UnauthorizedAccountError(code)
+
+ raise _InvalidUserError(user = code)
+
+ # We have succeeded!
+
+ def request_code(self, common_name, first_name, birth_date):
+ """ Request an email for an account without knowing the adherent
+ code. """
+
+ if type(common_name) != str:
+ raise ValueError("Common name must be a string.")
+ if type(first_name) != str:
+ raise ValueError("First name must be a string.")
+ if isinstance(birth_date, _date) \
+ or isinstance(birth_date, _datetime):
+ d = _date(birth_date.year, birth_date.month, birth_date.day)
+ else:
+ raise ValueError("Birth date must be a date.")
+
+ # Make the request.
+ # This is just pro level reverse engineering.
+ # Please do not read this if you do not want to lose your sanity.
+ # Please read this if you don't care.
+ # Please modify it if you ought to break it.
+
+ path = '/securite/OubliMotDePasse.aspx?num=1'
+ dfr = d.strftime("%d/%m/%Y")
+ dus = d.strftime("%Y-%m-%d")
+ cus = _datetime.now().strftime("%Y-%m-01")
+ eo_obj_states = _b64encode(b'\x01!\x01BA' \
+ b'ctl00_MainContent__eocDateNaissance__picker:' + \
+ cus.encode('ASCII') + b'|' + dus.encode('ASCII')).decode('ASCII')
+
+ result = self.get_page(path, args = {
+ '__eo_obj_states': eo_obj_states,
+ '__eo_sc': '/wEdAAcSMJ8leNaA/vPZOzulfZuPEvHhCaPIDCCxX3XQg2l5e+BQ' \
+ 'RR32SiwZGot7RnRIo09TjFeQwbB4Eo7GP2ptXnbAnMyq24KvMxlGsP7uRmE' \
+ 'TdaVE1CbS15+9uPB4lF6xMlznDHPtMay3TLJDvEyyRG6LaDe0jqt5G6yCDS' \
+ 'sQZSEP8hLNmoo=',
+ '_eo_js_modules': '',
+ '_eo_obj_inst': '',
+
+ '_eo_ctl00_MainContent__eocDateNaissance__picker_picker': dfr,
+ '_eo_ctl00_MainContent__eocDateNaissance__picker_h': dus,
+ 'ctl00': {
+ 'MainContent': {
+ '_tbNom': common_name,
+ '_tbPrenom': first_name}}},
+ method = self.METHOD_FORM,
+ hint = 'sic')
+
+ # Print the result.
+
+ node = result.find(id = 'ctl00__upMainContent')
+ print(node)
+
+ espan = result.find(id = 'ctl00__erreur__lblErreur')
+ if espan is not None:
+ # There has been an error!
+
+ etx = espan.text.strip()
+
+ noinfo = "Nous ne pouvons pas vous donner votre numéro " \
+ "d’adhérent pour la raison suivante : Pas de résultat avec " \
+ "les informations fournies"
+
+ if etx == noinfo:
+ raise ValueError("no result")
+
class IntranetSession(AnonymousIntranetSession):
""" Class for interacting with the intranet while logged in. """
- def __init__(self, manager, base = 'https://intranet.sgdf.fr',
- user = None, pw = None):
- super().__init__(manager, base)
+ def __init__(self, manager, user = None, pw = None, *args, **kwargs):
+ super().__init__(manager, *args, **kwargs)
self.__user = user
self.__pw = pw
@@ -320,7 +464,7 @@ class IntranetSession(AnonymousIntranetSession):
hint = 'intranet_functions')
# ---
- # Usage.
+ # Session-related.
# ---
def login(self, pw = None, session = None):
@@ -332,13 +476,13 @@ class IntranetSession(AnonymousIntranetSession):
e = None
try:
- temp = self.get_page('/Default.aspx', {
+ result = self.get_page('/Default.aspx', {
'ctl00': {
'MainContent': {
'login': self.__user,
'password': pw}}},
method = self.METHOD_FORM,
- hint = 'ignore',
+ hint = 'sic',
new_session = True)
except RedirectError:
# We have successfully been redirected!
@@ -346,11 +490,28 @@ class IntranetSession(AnonymousIntranetSession):
return
# The credentials were invalid.
- # FIXME: an appropriate exception?
- # FIXME: what if we were reconnecting?
self._restore_session()
- raise ValueError("invalid credentials")
+
+ espan = result.find(id = 'ctl00__erreur__lblErreur')
+ if espan is None:
+ raise _InvalidCredentialsError(user = self.__user, pw = pw)
+
+ error = espan.text.strip()
+ if error == "Identifiant invalide":
+ raise _InvalidUserError(self.__user)
+ elif error == "Mot de passe invalide":
+ raise _InvalidPasswordError(self.__user, pw)
+ elif error.startswith("Le compte associé à l'identifiant '") \
+ and error.endswith("' ne donne pas le droit d'utiliser cette " \
+ "application"):
+ raise _UnauthorizedAccountError(self.__user)
+ else:
+ raise _InvalidCredentialsError(user = self.__user, pw = pw)
+
+ # ---
+ # Obtain objects.
+ # ---
def _get_ops_page(self, ent_type, ent_iid, page):
""" Get an operation page. """
diff --git a/sgdfi/_manager.py b/sgdfi/_manager.py
index 72cae59..9a734fb 100755
--- a/sgdfi/_manager.py
+++ b/sgdfi/_manager.py
@@ -277,12 +277,13 @@ class _EventsManager:
class Manager(_Decoder):
""" Manage objects from SGDF's intranet. """
- def __init__(self, save = False, folder = None):
+ def __init__(self, save = False, folder = None, debug = False):
super().__init__()
self.__save = save
self.__folder = folder
self.__sessions = _SessionsManager(self)
+ self.__dbg = bool(debug)
# Work out the folder, and make sure it exists.
@@ -304,6 +305,12 @@ class Manager(_Decoder):
self.__evs = _EventsManager(self)
@property
+ def _debug(self):
+ """ Is the manager in debug mode? """
+
+ return self.__dbg
+
+ @property
def sessions(self):
""" Managed sessions. """
diff --git a/sgdfi/_util.py b/sgdfi/_util.py
index 088b1da..db097d8 100755
--- a/sgdfi/_util.py
+++ b/sgdfi/_util.py
@@ -18,7 +18,9 @@ from pytz import timezone as _timezone
__all__ = ["IID", "Enum",
"Property", "IIDProperty", "DateProperty", "BoolProperty", "EnumProperty",
- "ArrayProperty", "TextProperty", "ObjectProperty", "ValueProperty", "Base"]
+ "ArrayProperty", "TextProperty", "ObjectProperty", "ValueProperty", "Base",
+ "InvalidCredentialsError", "InvalidUserError", "InvalidPasswordError",
+ "UnauthorizedAccountError"]
# ---
# Attribute helpers.
@@ -477,4 +479,52 @@ class Base:
super().__delattr__(name, value)
attr.delete()
+# ---
+# Exceptions.
+# ---
+
+class InvalidCredentialsError(Exception):
+ """ Exception for when the credentials are invalid. """
+
+ def __init__(self, msg = None, user = None, pw = None):
+ if msg is None:
+ msg = f"Invalid credentials ({repr(user)}: {repr(pw)})."
+
+ super().__init__(msg)
+
+ self.__user = user
+ self.__pw = pw
+
+ @property
+ def user(self):
+ """ Username for which the credentials are invalid. """
+
+ return self.__user
+
+ @property
+ def pw(self):
+ """ Password which is involved in the error. """
+
+ return self.__pw
+
+class InvalidUserError(InvalidCredentialsError):
+ """ Exception for when the user identifier is invalid. """
+
+ def __init__(self, user):
+ super().__init__(f"Invalid identifier ({repr(user)}).", user = user)
+
+class InvalidPasswordError(InvalidCredentialsError):
+ """ Exception for when the password is invalid. """
+
+ def __init__(self, user, pw):
+ super().__init__(f"Invalid password for {repr(user)} ({repr(pw)}).",
+ user = user, pw = pw)
+
+class UnauthorizedAccountError(InvalidCredentialsError):
+ """ Exception for when the user is not allowed to log in. """
+
+ def __init__(self, user):
+ super().__init__(f"User with identifier {repr(user)} is not allowed " \
+ "to log in.", user = user)
+
# End of file.