From 09d18c2acdf7f81775d9c888f76a02787824582b Mon Sep 17 00:00:00 2001 From: Tomi Belan Date: Sat, 25 Jan 2025 21:32:37 +0100 Subject: [PATCH 1/5] update votr version --- .gitmodules | 3 --- eprihlaska/votr | 1 - pyproject.toml | 4 ++++ uv.lock | 14 +++++++++++++- 4 files changed, 17 insertions(+), 5 deletions(-) delete mode 100644 .gitmodules delete mode 160000 eprihlaska/votr diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 70841d5..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "eprihlaska/votr"] - path = eprihlaska/votr - url = https://github.com/fmfi-svt/votr.git diff --git a/eprihlaska/votr b/eprihlaska/votr deleted file mode 160000 index 6fa2103..0000000 --- a/eprihlaska/votr +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6fa21039926d6841f5a7c350114f486350d17957 diff --git a/pyproject.toml b/pyproject.toml index dafed24..db7eda0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dependencies = [ "munch>=4.0.0,<5", "urllib3==2.3.0", "pypdftk>=0.5", + "votr", ] [tool.uv] @@ -64,6 +65,9 @@ dev-dependencies = [ "xlrd>=2.0.1", ] +[tool.uv.sources] +votr = { git = "https://github.com/fmfi-svt/votr.git" } + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/uv.lock b/uv.lock index c22eaf0..4ddf680 100644 --- a/uv.lock +++ b/uv.lock @@ -183,7 +183,7 @@ name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ @@ -334,6 +334,7 @@ dependencies = [ { name = "tinycss2" }, { name = "urllib3" }, { name = "visitor" }, + { name = "votr" }, { name = "weasyprint" }, { name = "webencodings" }, { name = "werkzeug" }, @@ -396,6 +397,7 @@ requires-dist = [ { name = "tinycss2", specifier = "==0.6.1" }, { name = "urllib3", specifier = "==2.3.0" }, { name = "visitor", specifier = "==0.1.3" }, + { name = "votr", git = "https://github.com/fmfi-svt/votr.git" }, { name = "weasyprint", specifier = "==0.42" }, { name = "webencodings", specifier = "==0.5.1" }, { name = "werkzeug", specifier = "==3.1.3" }, @@ -1043,6 +1045,16 @@ version = "0.1.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/58/785fcd6de4210049da5fafe62301b197f044f3835393594be368547142b0/visitor-0.1.3.tar.gz", hash = "sha256:2c737903b2b6864ebc6167eef7cf3b997126f1aa94bdf590f90f1436d23e480a", size = 3260 } +[[package]] +name = "votr" +version = "0.0.0" +source = { git = "https://github.com/fmfi-svt/votr.git#50cd536ea0d7ccf2b3d2901e6a9b39b120e7a335" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "lxml" }, + { name = "requests" }, +] + [[package]] name = "weasyprint" version = "0.42" From 21f12896be1152adcd6ecfdbd63af9ec153444e9 Mon Sep 17 00:00:00 2001 From: Tomi Belan Date: Sat, 25 Jan 2025 21:48:32 +0100 Subject: [PATCH 2/5] add: admin login with SAML + Andrvotr instead of Cosign --- config-sample.py | 3 ++- eprihlaska/ais_utils.py | 25 +++++++++++++++---------- eprihlaska/views.py | 40 ++++++++++++---------------------------- 3 files changed, 29 insertions(+), 39 deletions(-) diff --git a/config-sample.py b/config-sample.py index 38e0025..14a469b 100644 --- a/config-sample.py +++ b/config-sample.py @@ -31,7 +31,8 @@ UA_CODE = 'UA-23362538-7' -COSIGN_PROXY_DIR = '/opt/cosign/proxy' +MY_ENTITY_ID = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' +ANDRVOTR_API_KEY = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' SUBMISSIONS_OPEN = True UPLOADS_ENABLED = True diff --git a/eprihlaska/ais_utils.py b/eprihlaska/ais_utils.py index 77a19a8..5fb204f 100644 --- a/eprihlaska/ais_utils.py +++ b/eprihlaska/ais_utils.py @@ -1,19 +1,24 @@ -import os import sys import re import flask.json from flask import url_for -DIR = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, DIR + '/votr/') - -from aisikl.context import Context # noqa from aisikl.app import Application # noqa import aisikl.portal # noqa - - -def create_context(cookies, origin='ais2-beta.uniba.sk'): - ctx = Context(cookies, ais_url='https://'+origin+'/') - return ctx +from fladgejt.login import create_client # noqa + + +def create_context(*, my_entity_id, andrvotr_api_key, andrvotr_authority_token, beta): + server = dict( + login_types=('saml_andrvotr',), + ais_url=('https://ais2-beta.uniba.sk/' if beta else 'https://ais2.uniba.sk/'), + ) + params = dict( + type='saml_andrvotr', + my_entity_id=my_entity_id, + andrvotr_api_key=andrvotr_api_key, + andrvotr_authority_token=andrvotr_authority_token, + ) + return create_client(server, params).context def test_ais(ctx): diff --git a/eprihlaska/views.py b/eprihlaska/views.py index bcc6f9b..48542c5 100644 --- a/eprihlaska/views.py +++ b/eprihlaska/views.py @@ -968,29 +968,21 @@ def admin_file_download(id, uuid): return send_from_directory(receipt_dir, file, as_attachment=True) -def get_cosign_cookies(): - name = request.environ['COSIGN_SERVICE'] - value = request.cookies[name] - filename = name + '=' + value.partition('/')[0] - result = {} - with open(os.path.join(app.config['COSIGN_PROXY_DIR'], - filename)) as f: - for line in f: - # Remove starting "x" and everything after the space. - name, _, value = line[1:].split()[0].partition('=') - result[name] = value - return result +def create_votr_context(*, beta): + from .ais_utils import create_context + return create_context( + my_entity_id=app.config['MY_ENTITY_ID'], + andrvotr_api_key=app.config['ANDRVOTR_API_KEY'], + andrvotr_authority_token=request.environ['ANDRVOTR_AUTHORITY_TOKEN'], + beta=beta, + ) @app.route('/admin/ais_test') @require_remote_user def admin_ais_test(): - from .ais_utils import (create_context, test_ais) - cosign_cookies = get_cosign_cookies() - ctx = create_context(cosign_cookies, - origin='ais2.uniba.sk') - # Do log in - ctx.request_html('/ais/loginCosign.do', method='POST') + from .ais_utils import test_ais + ctx = create_votr_context(beta=False) test_ais(ctx) return redirect(url_for('admin_list')) @@ -1140,17 +1132,9 @@ def admin_process_special(id, process_type): def send_application_to_ais2(id, application, form, process_type, beta=False): - from .ais_utils import (create_context, save_application_form) + from .ais_utils import save_application_form if form.validate_on_submit(): - origin = 'ais2.uniba.sk' - if beta: - origin = 'ais2-beta.uniba.sk' - - cosign_cookies = get_cosign_cookies() - ctx = create_context(cosign_cookies, - origin=origin) - # Do log in - ctx.request_html('/ais/loginCosign.do', method='POST') + ctx = create_votr_context(beta=beta) ais2_output = None error_output = None From a0483c6aafea6af2f657c0618aa2c218296aa29f Mon Sep 17 00:00:00 2001 From: Tomi Belan Date: Sat, 25 Jan 2025 22:00:22 +0100 Subject: [PATCH 3/5] update admin logout for mod_shib --- eprihlaska/templates/admin_impersonate_list.html | 2 +- eprihlaska/templates/admin_list.html | 2 +- eprihlaska/templates/admin_tokens_list.html | 2 +- eprihlaska/views.py | 12 +++++++----- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/eprihlaska/templates/admin_impersonate_list.html b/eprihlaska/templates/admin_impersonate_list.html index c3ff241..b800b33 100644 --- a/eprihlaska/templates/admin_impersonate_list.html +++ b/eprihlaska/templates/admin_impersonate_list.html @@ -12,7 +12,7 @@

diff --git a/eprihlaska/templates/admin_list.html b/eprihlaska/templates/admin_list.html index 5ae0a91..ca385bc 100644 --- a/eprihlaska/templates/admin_list.html +++ b/eprihlaska/templates/admin_list.html @@ -106,7 +106,7 @@

diff --git a/eprihlaska/templates/admin_tokens_list.html b/eprihlaska/templates/admin_tokens_list.html index 7b344ed..2123972 100644 --- a/eprihlaska/templates/admin_tokens_list.html +++ b/eprihlaska/templates/admin_tokens_list.html @@ -12,7 +12,7 @@

diff --git a/eprihlaska/views.py b/eprihlaska/views.py index 48542c5..4c37880 100644 --- a/eprihlaska/views.py +++ b/eprihlaska/views.py @@ -712,12 +712,14 @@ def signup(): @app.route('/logout', methods=['GET']) @login_required def logout(): - # Clear out the session - keys = list(session.keys()).copy() - for k in keys: - session.pop(k) - logout_user() + session.clear() + + if request.environ.get('REMOTE_USER'): + # Admin logout: clear our session, mod_shib session, and IdP session. + # (return parameter doesn't matter, Shibboleth IdP ignores it.) + return redirect('/Shibboleth.sso/Logout?return=/') + return redirect(url_for('index')) From 055335dc0dbb56c1ee0810c15182fffede442cbd Mon Sep 17 00:00:00 2001 From: Tomi Belan Date: Sat, 25 Jan 2025 23:24:07 +0100 Subject: [PATCH 4/5] add: save votr context in flask session --- eprihlaska/ais_utils.py | 2 +- eprihlaska/forms.py | 6 -- eprihlaska/templates/admin_list.html | 4 +- eprihlaska/templates/admin_process.html | 14 +-- eprihlaska/views.py | 118 +++++++++++++----------- 5 files changed, 70 insertions(+), 74 deletions(-) diff --git a/eprihlaska/ais_utils.py b/eprihlaska/ais_utils.py index 5fb204f..01aed4b 100644 --- a/eprihlaska/ais_utils.py +++ b/eprihlaska/ais_utils.py @@ -140,7 +140,7 @@ def save_application_form(ctx, # If the priezviskoTextField is not empty, it most probably means the # person is already registered in - if app.d.priezviskoTextField.value != '' and process_type is None: + if app.d.priezviskoTextField.value != '' and process_type == 'none': notes['person_exists'] = { 'name': app.d.menoTextField.value, 'surname': app.d.priezviskoTextField.value, diff --git a/eprihlaska/forms.py b/eprihlaska/forms.py index 4621d99..4d15a7e 100644 --- a/eprihlaska/forms.py +++ b/eprihlaska/forms.py @@ -345,11 +345,5 @@ class SignupForm(FlaskForm): submit = SubmitField(label=c.SIGNUP) -class AIS2CookieForm(FlaskForm): - jsessionid = StringField(label='JSESSIONID', - validators=[validators.DataRequired()]) - submit = SubmitField(label=c.SUBMIT) - - class AIS2SubmitForm(FlaskForm): submit = SubmitField(label=c.CONTINUE) diff --git a/eprihlaska/templates/admin_list.html b/eprihlaska/templates/admin_list.html index ca385bc..bc56355 100644 --- a/eprihlaska/templates/admin_list.html +++ b/eprihlaska/templates/admin_list.html @@ -47,8 +47,8 @@ {% endif %} {% if app.state.value == 2 %} - Preklopiť na BETU - Preklopiť + Preklopiť na BETU + Preklopiť {% endif %}

diff --git a/eprihlaska/templates/admin_process.html b/eprihlaska/templates/admin_process.html index 091ee33..dec5951 100644 --- a/eprihlaska/templates/admin_process.html +++ b/eprihlaska/templates/admin_process.html @@ -8,7 +8,7 @@

{% block title %} - {% if beta %} + {% if ais_instance == 'beta' %} Preklopenie prihlášky #{{ id }} do AIS2 (beta) {% else %} Preklopenie prihlášky #{{ id }} do AIS2 @@ -128,21 +128,13 @@

Vyplniť prihlášku bez párovania s osobou

diff --git a/eprihlaska/views.py b/eprihlaska/views.py index 4c37880..5cc1bb9 100644 --- a/eprihlaska/views.py +++ b/eprihlaska/views.py @@ -1,3 +1,5 @@ +import pickle +from urllib.parse import quote_plus from flask import (render_template, flash, redirect, session, request, url_for, make_response, send_from_directory) import flask.json @@ -9,7 +11,7 @@ AdmissionWaiversForm, FinalForm, ReceiptUploadForm, LoginForm, SignupForm, ForgottenPasswordForm, NewPasswordForm, - AIS2CookieForm, AIS2SubmitForm) + AIS2SubmitForm) from werkzeug.security import generate_password_hash, check_password_hash from flask_login import login_user, login_required, logout_user, current_user import datetime @@ -98,9 +100,17 @@ def save_form(form): save_current_session_to_DB() +def current_session_to_json(): + # Don't store internal keys used by flask (e.g. "_flashes"), flask-login + # (e.g. "_user_id", "_fresh", "_id") and our admin ("_votr_context_*") in + # the SQL database. + filtered = { k: v for k, v in session.items() if not k.startswith('_') } + return flask.json.dumps(filtered) + + def save_current_session_to_DB(): app = ApplicationForm.query.filter_by(user_id=current_user.id).first() - app.application = flask.json.dumps(dict(session)) + app.application = current_session_to_json() db.session.commit() @@ -126,7 +136,7 @@ def load_session(): if 'application_submitted' in session: del session['application_submitted'] - app.application = flask.json.dumps(dict(session)) + app.application = current_session_to_json() db.session.commit() session.modified = True @@ -469,7 +479,7 @@ def final(): save_form(form) session['application_submitted'] = True - app_form.application = flask.json.dumps(dict(session)) + app_form.application = current_session_to_json() app_form.state = ApplicationStates.submitted app_form.submitted_at = datetime.datetime.now() db.session.commit() @@ -692,7 +702,7 @@ def signup(): new_application_form = ApplicationForm(user_id=new_user.id) # FIXME: band-aid for last_updated_at new_application_form.last_updated_at = datetime.datetime.now() - new_application_form.application = flask.json.dumps(dict(session)) + new_application_form.application = current_session_to_json() db.session.add(new_application_form) db.session.commit() @@ -794,7 +804,7 @@ def create_or_get_user_and_login(site, token, name, surname, email): new_application_form = ApplicationForm(user_id=user.id) # FIXME: band-aid for last_updated_at new_application_form.last_updated_at = datetime.datetime.now() - new_application_form.application = flask.json.dumps(dict(session)) + new_application_form.application = current_session_to_json() db.session.add(new_application_form) db.session.commit() @@ -970,22 +980,53 @@ def admin_file_download(id, uuid): return send_from_directory(receipt_dir, file, as_attachment=True) -def create_votr_context(*, beta): +@app.route('/admin/init_votr/') +@require_remote_user +def admin_init_votr(ais_instance): + next = request.args['next'] + if not next.startswith('/admin/'): + return 'Bad next', 400 + from .ais_utils import create_context - return create_context( + votr_context = create_context( my_entity_id=app.config['MY_ENTITY_ID'], andrvotr_api_key=app.config['ANDRVOTR_API_KEY'], andrvotr_authority_token=request.environ['ANDRVOTR_AUTHORITY_TOKEN'], - beta=beta, + beta=(ais_instance == 'beta'), ) + # flask-session docs say it'll stop using pickle in the future. + # Wrap the votr_context in our own pickle. + session[f'_votr_context_{ais_instance}'] = pickle.dumps(votr_context, pickle.HIGHEST_PROTOCOL) + + return redirect(next) + + +def require_votr_context(func): + @wraps(func) + def wrapper(*args, **kwargs): + ais_instance = kwargs['ais_instance'] + session_key = f'_votr_context_{ais_instance}' + if not session.get(session_key): + # First time we visited an admin page that requires votr_context? + # 1. Re-login with mod_shib to get a fresh andrvotr authority token. + # 2. Create a votr_context and store it in the flask session. + # 3. Redirect back here. + target = url_for('admin_init_votr', ais_instance=ais_instance, next=request.full_path) + return redirect(f'/Shibboleth.sso/Login?target={quote_plus(target)}') + + votr_context = pickle.loads(session[session_key]) + result = func(*args, **kwargs, votr_context=votr_context) + session[session_key] = pickle.dumps(votr_context, pickle.HIGHEST_PROTOCOL) + return result + return wrapper -@app.route('/admin/ais_test') +@app.route('/admin/ais_test/') @require_remote_user -def admin_ais_test(): +@require_votr_context +def admin_ais_test(ais_instance, votr_context): from .ais_utils import test_ais - ctx = create_votr_context(beta=False) - test_ais(ctx) + test_ais(votr_context) return redirect(url_for('admin_list')) @@ -1096,61 +1137,29 @@ def generate(): mimetype='text/tsv', headers=headers) -@app.route('/admin/ais2_process/', methods=['GET', 'POST']) +@app.route('/admin/ais2_process///', methods=['GET', 'POST']) @require_remote_user -def admin_ais2_process(id): +@require_votr_context +def admin_ais2_process(ais_instance, process_type, id, votr_context): application = ApplicationForm.query.get(id) form = AIS2SubmitForm() - return send_application_to_ais2(id, application, form, - process_type=None, beta=False) - -@app.route('/admin/ais2_process//', methods=['GET', 'POST']) -def admin_ais2_process_special(id, process_type): - application = ApplicationForm.query.get(id) - - form = AIS2SubmitForm() - return send_application_to_ais2(id, application, form, - process_type=process_type, beta=False) - - -@app.route('/admin/process/', methods=['GET', 'POST']) -@require_remote_user -def admin_process(id): - application = ApplicationForm.query.get(id) - - form = AIS2SubmitForm() - return send_application_to_ais2(id, application, form, None, beta=True) - - -@app.route('/admin/process//', methods=['GET', 'POST']) -def admin_process_special(id, process_type): - application = ApplicationForm.query.get(id) - - form = AIS2CookieForm() - return send_application_to_ais2(id, application, form, process_type, - beta=True) - - -def send_application_to_ais2(id, application, form, process_type, beta=False): from .ais_utils import save_application_form if form.validate_on_submit(): - ctx = create_votr_context(beta=beta) - ais2_output = None error_output = None notes = {} logfile = None try: - if beta: + if ais_instance == 'beta': import gzip import random logfile = gzip.open('/tmp/log_%s_%s' % (id, random.randrange(10000)), 'wt', encoding='utf8') - ctx.logger.log_file = logfile + votr_context.logger.log_file = logfile - ais2_output, notes = save_application_form(ctx, + ais2_output, notes = save_application_form(votr_context, application, LISTS, id, @@ -1176,7 +1185,7 @@ def send_application_to_ais2(id, application, form, process_type, beta=False): # Only update the application state of it is not sent to beta and the # 'person_exists' note is not added - if not beta and error_output is None and 'person_exists' not in notes: + if ais_instance != 'beta' and error_output is None and 'person_exists' not in notes: application.state = ApplicationStates.processed db.session.commit() @@ -1185,13 +1194,14 @@ def send_application_to_ais2(id, application, form, process_type, beta=False): ais2_output=ais2_output, notes=notes, id=id, error_output=error_output, - beta=beta, process_type=process_type, + ais_instance=ais_instance, + process_type=process_type, session=sess, lists=LISTS) return render_template('admin_process.html', form=form, id=id, - beta=beta) + ais_instance=ais_instance) @app.route('/admin/impersonate/list') From 27ca064030427fd8967f127f7c95021bd99af356 Mon Sep 17 00:00:00 2001 From: Tomi Belan Date: Fri, 7 Feb 2025 13:08:11 +0100 Subject: [PATCH 5/5] fix: create new context if AIS login expires --- eprihlaska/views.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/eprihlaska/views.py b/eprihlaska/views.py index 5cc1bb9..3d8f8cc 100644 --- a/eprihlaska/views.py +++ b/eprihlaska/views.py @@ -1001,20 +1001,33 @@ def admin_init_votr(ais_instance): return redirect(next) +def _redirect_to_init_votr(ais_instance): + # First time we visited an admin page that requires votr_context? + # Or has AIS login expired? + # 1. Re-login with mod_shib to get a fresh andrvotr authority token. + # 2. Create a votr_context and store it in the flask session. + # 3. Redirect back here. + target = url_for('admin_init_votr', ais_instance=ais_instance, next=request.full_path) + return redirect(f'/Shibboleth.sso/Login?target={quote_plus(target)}') + + def require_votr_context(func): @wraps(func) def wrapper(*args, **kwargs): ais_instance = kwargs['ais_instance'] session_key = f'_votr_context_{ais_instance}' if not session.get(session_key): - # First time we visited an admin page that requires votr_context? - # 1. Re-login with mod_shib to get a fresh andrvotr authority token. - # 2. Create a votr_context and store it in the flask session. - # 3. Redirect back here. - target = url_for('admin_init_votr', ais_instance=ais_instance, next=request.full_path) - return redirect(f'/Shibboleth.sso/Login?target={quote_plus(target)}') + return _redirect_to_init_votr(ais_instance) votr_context = pickle.loads(session[session_key]) + + from aisikl.app import check_connection + from aisikl.exceptions import LoggedOutError + try: + check_connection(votr_context) + except LoggedOutError: + return _redirect_to_init_votr(ais_instance) + result = func(*args, **kwargs, votr_context=votr_context) session[session_key] = pickle.dumps(votr_context, pickle.HIGHEST_PROTOCOL) return result