@ -0,0 +1,161 @@ | |||
# Byte-compiled / optimized / DLL files | |||
__pycache__/ | |||
*.py[cod] | |||
*$py.class | |||
# C extensions | |||
*.so | |||
# Distribution / packaging | |||
.Python | |||
build/ | |||
develop-eggs/ | |||
dist/ | |||
downloads/ | |||
eggs/ | |||
.eggs/ | |||
lib/ | |||
lib64/ | |||
parts/ | |||
sdist/ | |||
var/ | |||
wheels/ | |||
share/python-wheels/ | |||
*.egg-info/ | |||
.installed.cfg | |||
*.egg | |||
MANIFEST | |||
# PyInstaller | |||
# Usually these files are written by a python script from a template | |||
# before PyInstaller builds the exe, so as to inject date/other infos into it. | |||
*.manifest | |||
*.spec | |||
# Installer logs | |||
pip-log.txt | |||
pip-delete-this-directory.txt | |||
# Unit test / coverage reports | |||
htmlcov/ | |||
.tox/ | |||
.nox/ | |||
.coverage | |||
.coverage.* | |||
.cache | |||
nosetests.xml | |||
coverage.xml | |||
*.cover | |||
*.py,cover | |||
.hypothesis/ | |||
.pytest_cache/ | |||
cover/ | |||
# Translations | |||
*.mo | |||
*.pot | |||
# Django stuff: | |||
*.log | |||
local_settings.py | |||
db.sqlite3 | |||
db.sqlite3-journal | |||
# Flask stuff: | |||
instance/ | |||
flask_session/ | |||
.webassets-cache | |||
# Scrapy stuff: | |||
.scrapy | |||
# Sphinx documentation | |||
docs/_build/ | |||
# PyBuilder | |||
.pybuilder/ | |||
target/ | |||
# Jupyter Notebook | |||
.ipynb_checkpoints | |||
# IPython | |||
profile_default/ | |||
ipython_config.py | |||
# pyenv | |||
# For a library or package, you might want to ignore these files since the code is | |||
# intended to run in multiple environments; otherwise, check them in: | |||
# .python-version | |||
# pipenv | |||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. | |||
# However, in case of collaboration, if having platform-specific dependencies or dependencies | |||
# having no cross-platform support, pipenv may install dependencies that don't work, or not | |||
# install all needed dependencies. | |||
#Pipfile.lock | |||
# poetry | |||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. | |||
# This is especially recommended for binary packages to ensure reproducibility, and is more | |||
# commonly ignored for libraries. | |||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control | |||
#poetry.lock | |||
# pdm | |||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. | |||
#pdm.lock | |||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it | |||
# in version control. | |||
# https://pdm.fming.dev/#use-with-ide | |||
.pdm.toml | |||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm | |||
__pypackages__/ | |||
# Celery stuff | |||
celerybeat-schedule | |||
celerybeat.pid | |||
# SageMath parsed files | |||
*.sage.py | |||
# Environments | |||
.env | |||
.venv | |||
env/ | |||
venv/ | |||
ENV/ | |||
env.bak/ | |||
venv.bak/ | |||
# Spyder project settings | |||
.spyderproject | |||
.spyproject | |||
# Rope project settings | |||
.ropeproject | |||
# mkdocs documentation | |||
/site | |||
# mypy | |||
.mypy_cache/ | |||
.dmypy.json | |||
dmypy.json | |||
# Pyre type checker | |||
.pyre/ | |||
# pytype static type analyzer | |||
.pytype/ | |||
# Cython debug symbols | |||
cython_debug/ | |||
# PyCharm | |||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can | |||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore | |||
# and can be added to the global gitignore or merged into this file. For a more nuclear | |||
# option (not recommended) you can uncomment the following to ignore the entire idea folder. | |||
#.idea/ |
@ -0,0 +1,10 @@ | |||
[build-system] | |||
requires = [ | |||
"setuptools>=42", | |||
"wheel", | |||
"setuptools_scm[toml]>3.4", | |||
] | |||
build-backend = "setuptools.build_meta" | |||
[tool.setuptools_scm] | |||
write_to = "src/zolfa/zauth/version.py" |
@ -0,0 +1,31 @@ | |||
[metadata] | |||
name = Zauth | |||
version = attr:zolfa.zauth.version | |||
author = Zolfa | |||
author_email = zolfa@lilik.it | |||
description = Helpers to authenticate and programmatically use external websites. | |||
classifiers = | |||
Development Status :: 1 - Planning | |||
Programming Language :: Python | |||
Programming Language :: Python :: 3.11 | |||
platforms = any | |||
[options] | |||
include_package_data = True | |||
packages = find_namespace: | |||
package_dir = | |||
= src | |||
install_requires = | |||
requests | |||
lxml | |||
setup_requires = | |||
setuptools_scm | |||
[options.packages.find] | |||
where = src | |||
include = zolfa.* | |||
[options.entry_points] | |||
console_scripts = | |||
serve_freebeer=stapa.freebeer.serve:serve |
@ -0,0 +1,122 @@ | |||
import logging | |||
import random | |||
import re | |||
from http.cookiejar import Cookie | |||
from urllib.parse import urljoin, urlparse, parse_qs | |||
import requests | |||
from .utils.webclient import WebClient | |||
from .utils.exceptions import * | |||
logger = logging.getLogger(__name__) | |||
logger.setLevel(logging.INFO) | |||
class PasteurSSO: | |||
HEADERS = { | |||
'User-Agent': ("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:104.0)" | |||
" Gecko/20100101 Firefox/104.0)") | |||
} | |||
SP_START = "https://connect.pasteur.fr" | |||
IDP_ENTRY_POINT = "https://idp.pasteur.fr/idp/profile/SAML2/POST/SSO" | |||
def __init__(self, username, password): | |||
self.username = username | |||
self.password = password | |||
self.client = WebClient(headers=self.HEADERS) | |||
def authenticate(self): | |||
# Go to SP authentication start page and look for SAML form. | |||
logger.info(f"SP: Starting authentication from {self.SP_START}") | |||
self.client.get(self.SP_START) | |||
if not self.client.find_forms(action=self.IDP_ENTRY_POINT): | |||
logger.info("SP: SAML form not found: already logged in?") | |||
self.verify_sp_auth() | |||
return | |||
# Send SAML form to the IDP entry point | |||
logger.info(f"SP: SAML form found: submitting to IDP") | |||
self.client.select_form(action=self.IDP_ENTRY_POINT) | |||
self.client.submit_form() | |||
if self.client.status_code == 400: | |||
raise Exception("IDP: Error 400: SAML request probably expired.") | |||
# Check if already authenticated to IDP | |||
self.client.select_form() | |||
if 'SAMLResponse' in self.client.form: | |||
logger.info("IDP: Already authenticated") | |||
else: | |||
# Perform authentication with IDP | |||
logger.info("IDP: Authentication required.") | |||
self.client.select_form() | |||
self.client.submit_form() | |||
self.client.select_form() | |||
self.client.form['j_username'] = self.username | |||
self.client.form['j_password'] = self.password | |||
self.client.form['_eventId_proceed'] = "" | |||
# Make authentication persistent | |||
self.client.form['donotcache'] = "0" | |||
logger.info(f"IDP: Authenticating '{self.username}'.") | |||
self.client.submit_form() | |||
self.client.select_form() | |||
if 'SAMLResponse' not in self.client.form: | |||
raise Exception("IDP: Authentication failed.") | |||
# Send IDP SAMLResponse back to SP | |||
logger.info("IDP: Got SAMLResponse, sending to SP") | |||
self.client.submit_form() | |||
self.sp_after_saml() | |||
def verify_sp_auth(self): | |||
m = re.match( | |||
r"https://connect.pasteur.fr/f5-w-[0-9a-f]+\$\$/connect/$", | |||
sso.client.url | |||
) | |||
if not m: | |||
raise Exception("SP: unlogged and not redirecting to IDP!") | |||
def sp_after_saml(self): | |||
pass | |||
class PasteurEmail(PasteurSSO): | |||
SP_START = "https://email.pasteur.fr" | |||
def verify_sp_auth(self): | |||
if self.client.url != 'https://email.pasteur.fr/owa/': | |||
raise Exception("SP: unlogged and not redirecting to IDP!") | |||
class PasteurSAP(PasteurSSO): | |||
SP_START = "https://portailha.pasteur.fr" | |||
def sp_after_saml(self): | |||
self.client.select_form(action="/sap/bc/ui2/nwbc") | |||
self.client.submit_form() | |||
def verify_sp_auth(self): | |||
pass | |||
class PasteurEurofins(PasteurSAP): | |||
def authenticate(self): | |||
self.client.get("https://b2b.eurofinsgenomics.eu") | |||
if self.client.url == "https://b2b.eurofinsgenomics.eu/": | |||
return True | |||
super().authenticate() | |||
self.client.get( | |||
f"https://portailha.pasteur.fr/sap/opu/odata/srmnxp" | |||
f"/CATALOG_LAUNCH_DETAILS/PollDetails(LAUNCH_FROM='PUNCH_OUT'," | |||
f"SERVICE_ID='PEUROFINS2',OBJECT_ID='442078',PRODUCTID='')/?=" | |||
f"&random={random.random()}&random={random.random()}", | |||
headers={'Accept': "application/json"} | |||
) | |||
launch_data = self.client.res.json()['d'] | |||
self.client.post( | |||
launch_data['SERVICE_URL'], | |||
data=launch_data['FORM_DATA'] | |||
) |
@ -0,0 +1,34 @@ | |||
class HTTPError(Exception): | |||
def __init__(self, ans): | |||
self.ans = ans | |||
self.status_code = ans.status_code | |||
super().__init__(f"Unexpected Status Code: {self.status_code}") | |||
class TooManyFormsError(Exception): | |||
def __init__(self, res, filters): | |||
self.res = res | |||
self.filters = filters | |||
message = f"Too many forms matched the filters: {filters}." | |||
super().__init__(message) | |||
class FormNotFoundError(Exception): | |||
def __init__(self, res, filters): | |||
self.res = res | |||
self.filters = filters | |||
message = f"No forms matched the filters: {filters}." | |||
super().__init__(message) | |||
class NoFormSelectedError(Exception): | |||
def __init__(self, function): | |||
message = (f"Function '{function}' has been called before selecting " | |||
f" any form.") | |||
super().__init__(message) | |||
class RemoteException(Exception): | |||
def __init__(self, message, ans): | |||
self.ans = ans | |||
super().__init__(message) |
@ -0,0 +1,84 @@ | |||
from urllib.parse import urljoin | |||
import requests | |||
from lxml.etree import HTML | |||
from .exceptions import * | |||
class WebClientForm: | |||
def __init__(self, form): | |||
self.action = form.attrib['action'] | |||
self.method = form.attrib.get('method', "GET").upper() | |||
self.data = {i.attrib['name']: i.attrib.get('value', "") | |||
for i in form.xpath("//input") | |||
if 'name' in i.attrib} | |||
def update(self, items): | |||
for k, v in items.items(): | |||
self[k] = v | |||
def __getitem__(self, k): | |||
return self.data[k] | |||
def __setitem__(self, k, v): | |||
self.data.update({k: v}) | |||
def __contains__(self, k): | |||
return k in self.data | |||
def __iter__(self): | |||
return self.data.items() | |||
def __repr__(self): | |||
return str((self.method, self.action, self.data)) | |||
class WebClient(requests.Session): | |||
def __init__(self, headers={}, **kwargs): | |||
self.url = "" | |||
self.res = None | |||
self.form = None | |||
super().__init__(**kwargs) | |||
self.headers.update(headers) | |||
def send(self, *args, **kwargs): | |||
r = super().send(*args, **kwargs) | |||
self.res = r | |||
self.url = r.url | |||
self.status_code = r.status_code | |||
self.form = None | |||
return r | |||
def find_forms(self, **filters): | |||
if not self.res: | |||
return [] | |||
tree = HTML(self.res.content) | |||
filters_xpath = [f"[@{k}='{v}']" for k, v in filters.items()] | |||
filters_xpath = "".join(filters_xpath) | |||
return tree.xpath("//form" + filters_xpath) | |||
def select_form(self, **filters): | |||
forms = self.find_forms(**filters) | |||
if len(forms) != 1: | |||
if forms: | |||
raise TooManyFormsError(self.res, filters) | |||
else: | |||
raise FormNotFoundError(self.res, filters) | |||
self.form = WebClientForm(forms[0]) | |||
def update_form(self, **arguments): | |||
if not self.form: | |||
raise NoFormSelectedError('update_form') | |||
def submit_form(self): | |||
if not self.form: | |||
raise NoFormSelectedError('submit_form') | |||
if self.form.method not in ["POST"]: | |||
raise NotImplementedError( | |||
f"Submit method '{self.form.method}' not supported.") | |||
self.request( | |||
self.form.method, | |||
urljoin(self.url, self.form.action), | |||
data=self.form.data | |||
) |