Skip to content

Commit

Permalink
Separates LTI sessions from regular sessions
Browse files Browse the repository at this point in the history
  • Loading branch information
mpiraux committed Oct 22, 2024
1 parent 11ebad4 commit 262f9fd
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 140 deletions.
63 changes: 30 additions & 33 deletions inginious/frontend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,15 @@ def get_homepath():
""" Returns the URL root. """
return flask.request.url_root[:-1]

def get_path(*path_parts, force_cookieless=False):
def get_path(*path_parts):
"""
:param path_parts: List of elements in the path to be separated by slashes
:param force_cookieless: Force the cookieless session; the link will include the session_creator if needed.
"""
session = flask.session
request = flask.request
query_delimiter = '&' if path_parts and '?' in path_parts[-1] else '?'
lti_session_id = flask.request.args.get('session_id', flask.g.get('lti_session_id'))
path_parts = (get_homepath(), ) + path_parts
if session.sid is not None and session.cookieless:
return "/".join(path_parts) + f"{query_delimiter}session_id={session.sid}"
if force_cookieless:
return "/".join(path_parts) + f"{query_delimiter}session_id="
if lti_session_id:
query_delimiter = '&' if path_parts and '?' in path_parts[-1] else '?'
return "/".join(path_parts) + f"{query_delimiter}session_id={lti_session_id}"
return "/".join(path_parts)


Expand Down Expand Up @@ -165,30 +161,6 @@ def get_app(config):
"sessions", config.get('SESSION_USE_SIGNER', False), True # config.get('SESSION_PERMANENT', True)
)

# Init gettext
available_translations = {
"de": "Deutsch",
"el": "ελληνικά",
"es": "Español",
"fr": "Français",
"he": "עִבְרִית",
"nl": "Nederlands",
"nb_NO": "Norsk (bokmål)",
"pt": "Português",
"vi": "Tiếng Việt"
}

available_languages = {"en": "English"}
available_languages.update(available_translations)

l10n_manager = L10nManager()

l10n_manager.translations["en"] = gettext.NullTranslations() # English does not need translation ;-)
for lang in available_translations.keys():
l10n_manager.translations[lang] = gettext.translation('messages', get_root_path() + '/frontend/i18n', [lang])

builtins.__dict__['_'] = l10n_manager.gettext

if config.get("maintenance", False):
template_helper = TemplateHelper(PluginManager(), None, config.get('use_minified_js', True))
template_helper.add_to_template_globals("get_homepath", get_homepath)
Expand Down Expand Up @@ -227,6 +199,7 @@ def get_app(config):
taskset_factory, course_factory, task_factory = create_factories(fs_provider, default_task_dispensers, default_problem_types, plugin_manager, database)

user_manager = UserManager(database, config.get('superadmins', []))
flask.request_finished.connect(UserManager._lti_session_save, flask_app)

update_pending_jobs(database)

Expand All @@ -241,6 +214,30 @@ def get_app(config):

is_tos_defined = config.get("privacy_page", "") and config.get("terms_page", "")

# Init gettext
available_translations = {
"de": "Deutsch",
"el": "ελληνικά",
"es": "Español",
"fr": "Français",
"he": "עִבְרִית",
"nl": "Nederlands",
"nb_NO": "Norsk (bokmål)",
"pt": "Português",
"vi": "Tiếng Việt"
}

available_languages = {"en": "English"}
available_languages.update(available_translations)

l10n_manager = L10nManager(user_manager)

l10n_manager.translations["en"] = gettext.NullTranslations() # English does not need translation ;-)
for lang in available_translations.keys():
l10n_manager.translations[lang] = gettext.translation('messages', get_root_path() + '/frontend/i18n', [lang])

builtins.__dict__['_'] = l10n_manager.gettext

# Init web mail
mail.init_app(flask_app)

Expand Down
52 changes: 22 additions & 30 deletions inginious/frontend/flask/mongo_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# https://flasksession.readthedocs.io/

import re
from datetime import datetime
from datetime import datetime, timezone
from bson.objectid import ObjectId

try:
Expand All @@ -20,19 +20,18 @@
from werkzeug.datastructures import CallbackDict
from flask.sessions import SessionInterface
from werkzeug.exceptions import HTTPException
from inginious.frontend.pages.lti import LTILaunchPage
from inginious.frontend.pages.lti import LTILaunchPage, LTIOIDCLoginPage


class MongoDBSession(CallbackDict, SessionMixin):
"""Baseclass for server-side based sessions."""

def __init__(self, initial=None, sid=None, permanent=None, cookieless=False):
def __init__(self, initial=None, sid=None, permanent=None):
def on_update(self):
self.modified = True
CallbackDict.__init__(self, initial, on_update)
self.sid = sid
self.modified = False
self.cookieless = cookieless
if permanent:
self.permanent = permanent

Expand Down Expand Up @@ -67,31 +66,26 @@ def _get_signer(self, app):
key_derivation='hmac')

def open_session(self, app, request):
# Check for cookieless session in the path
path_session = request.args.get('session_id')
# Check for LTI session in the path
lti_session = request.args.get('session_id')

# Check if currently accessed URL is LTI launch page
# Check if currently accessed URL is LTI launch pages
try:
# request.url_rule is not set yet here.
endpoint, _ = app.create_url_adapter(request).match()
is_lti_launch = endpoint == LTILaunchPage.endpoint
is_lti_launch = endpoint in [LTIOIDCLoginPage.endpoint, LTILaunchPage.endpoint]
except HTTPException:
is_lti_launch = False

if path_session: # Cookieless session
cookieless = True
sid = path_session
elif is_lti_launch:
cookieless = True
sid = None
else:
cookieless = False
sid = request.cookies.get(self.get_cookie_name(app))
if lti_session or is_lti_launch:
return None

sid = request.cookies.get(self.get_cookie_name(app))

if not sid:
sid = self._generate_sid()
return self.session_class(sid=sid, permanent=self.permanent, cookieless=cookieless)
if not path_session and self.use_signer:
return self.session_class(sid=sid, permanent=self.permanent)
if self.use_signer:
signer = self._get_signer(app)
if signer is None:
return None
Expand All @@ -100,22 +94,22 @@ def open_session(self, app, request):
sid = sid_as_bytes.decode()
except BadSignature:
sid = self._generate_sid()
return self.session_class(sid=sid, permanent=self.permanent, cookieless=cookieless)
return self.session_class(sid=sid, permanent=self.permanent)

store_id = sid
document = self.store.find_one({'_id': store_id})
if document and document.get('expiration') <= datetime.utcnow():
if document and document.get('expiration') <= datetime.now(timezone.utc):
# Delete expired session
self.store.delete_one({'_id': store_id})
document = None
if document is not None:
try:
val = document['data']
data = self.serializer.loads(want_bytes(val))
return self.session_class(data, sid=sid, cookieless=cookieless)
return self.session_class(data, sid=sid)
except:
return self.session_class(sid=sid, permanent=self.permanent, cookieless=cookieless)
return self.session_class(sid=sid, permanent=self.permanent, cookieless=cookieless)
return self.session_class(sid=sid, permanent=self.permanent)
return self.session_class(sid=sid, permanent=self.permanent)

def save_session(self, app, session, response):
domain = self.get_cookie_domain(app)
Expand All @@ -130,16 +124,14 @@ def save_session(self, app, session, response):
httponly = self.get_cookie_httponly(app)
secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session)
cookieless = session.cookieless
val = self.serializer.dumps(dict(session))
self.store.update_one({'_id': store_id},
{"$set": {'data': val, 'expiration': expires, 'cookieless': cookieless}},
{"$set": {'data': val, 'expiration': expires}},
upsert=True)
if self.use_signer:
session_id = self._get_signer(app).sign(session.sid).decode()
else:
session_id = session.sid
if not cookieless:
response.set_cookie(self.get_cookie_name(app), session_id,
expires=expires, httponly=httponly,
domain=domain, path=path, secure=secure)
response.set_cookie(self.get_cookie_name(app), session_id,
expires=expires, httponly=httponly,
domain=domain, path=path, secure=secure)
6 changes: 3 additions & 3 deletions inginious/frontend/l10n_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@

class L10nManager:

def __init__(self):
def __init__(self, user_manager):
self.translations = {}
self._session = flask.session
self._user_manager = user_manager

def get_translation_obj(self, lang=None):
if lang is None:
lang = self._session.get("language", "") if flask.has_app_context() else ""
lang = self._user_manager.session_language(default="") if flask.has_app_context() else ""
return self.translations.get(lang, gettext.NullTranslations())

def gettext(self, text):
Expand Down
64 changes: 22 additions & 42 deletions inginious/frontend/pages/lti.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# This file is part of INGInious. See the LICENSE and the COPYRIGHTS files for
# more information about the licensing of this file.

import hashlib
import flask
from flask import jsonify, redirect
from werkzeug.exceptions import Forbidden, NotFound
Expand Down Expand Up @@ -53,51 +54,32 @@ class LTIBindPage(INGIniousAuthPage):
def is_lti_page(self):
return False

def fetch_lti_data(self, session_id):
""" Retrieves the corresponding session. """
# TODO : Flask session interface does not allow to open a specific session
# It could be worth putting these information outside of the session dict
sess = self.database.sessions.find_one({"_id": session_id})
if sess:
cookieless_session = self.app.session_interface.serializer.loads(want_bytes(sess['data']))
else:
return KeyError()
return session_id, cookieless_session["lti"]

def GET_AUTH(self):
input_data = flask.request.args
if "session_id" not in input_data:
return self.template_helper.render("lti_bind.html", success=False, session_id="",
data=None, error=_("Missing LTI session id"))
def _get_lti_session_data(self):
if not self.user_manager.session_is_lti():
return self.template_helper.render("lti_bind.html", success=False, data=None, error=_("Missing LTI session id"))

try:
cookieless_session_id, data = self.fetch_lti_data(input_data["session_id"])
except KeyError:
return self.template_helper.render("lti_bind.html", success=False, session_id="",
data=None, error=_("Invalid LTI session id"))
data = self.user_manager.session_lti_info()
if data is None:
return None, self.template_helper.render("lti_bind.html", success=False, data=None, error=_("Invalid LTI session id"))
return data, None

return self.template_helper.render("lti_bind.html", success=False,
session_id=cookieless_session_id, data=data, error="")
def GET_AUTH(self):
data, error = self._get_lti_session_data()
if error:
return error
return self.template_helper.render("lti_bind.html", success=False, data=data, error="")

def POST_AUTH(self):
input_data = flask.request.args
if "session_id" not in input_data:
return self.template_helper.render("lti_bind.html",success=False, session_id="",
data= None, error=_("Missing LTI session id"))

try:
cookieless_session_id, data = self.fetch_lti_data(input_data["session_id"])
except KeyError:
return self.template_helper.render("lti_bind.html", success=False, session_id="",
data=None, error=_("Invalid LTI session id"))
data, error = self._get_lti_session_data_or_error()
if error:
return error

try:
course = self.course_factory.get_course(data["task"][0])
if data["platform_instance_id"] not in course.lti_platform_instances_ids():
raise Exception()
except:
return self.template_helper.render("lti_bind.html", success=False, session_id="",
data=None, error=_("Invalid LTI data"))
return self.template_helper.render("lti_bind.html", success=False, data=None, error=_("Invalid LTI data"))

if data:
user_profile = self.database.users.find_one({"username": self.user_manager.session_username()})
Expand All @@ -116,13 +98,10 @@ def POST_AUTH(self):
data["task"][0],
data["platform_instance_id"],
user_profile.get("ltibindings", {}).get(data["task"][0], {}).get(data["platform_instance_id"], ""))
return self.template_helper.render("lti_bind.html", success=False,
session_id=cookieless_session_id,
data=data,
return self.template_helper.render("lti_bind.html", success=False, data=data,
error=_("Your account is already bound with this context."))

return self.template_helper.render("lti_bind.html", success=True,
session_id=cookieless_session_id, data=data, error="")
return self.template_helper.render("lti_bind.html", success=True, data=data, error="")


class LTIJWKSPage(INGIniousPage):
Expand Down Expand Up @@ -205,7 +184,8 @@ def _handle_message_launch(self, courseid, taskid):
can_report_grades = message_launch.has_ags() and tool_conf.get_iss_config(iss=message_launch.get_iss(),
client_id=message_launch.get_client_id()).get('auth_token_url')

self.user_manager.create_lti_session(user_id, roles, realname, email, courseid, taskid, platform_instance_id,
session_id = hashlib.sha256(launch_id.encode('utf-8')).digest().hex() # TODO(mp): Make this more secure
self.user_manager.create_lti_session(session_id, user_id, roles, realname, email, courseid, taskid, platform_instance_id,
launch_id if can_report_grades else None, tool_name, tool_desc, tool_url, context_title, context_label)

loggedin = self.user_manager.attempt_lti_login()
Expand Down Expand Up @@ -259,7 +239,7 @@ def GET(self):
if data["platform_instance_id"] not in course.lti_platform_instances_ids():
raise Exception()
except:
return self.template_helper.render("lti_bind.html", success=False, session_id="",
return self.template_helper.render("lti_bind.html", success=False,
data=None, error=_("Invalid LTI data"))

user_profile = self.database.users.find_one({"ltibindings." + data["task"][0] + "." + data["platform_instance_id"]: data["username"]})
Expand Down
4 changes: 2 additions & 2 deletions inginious/frontend/pages/social.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def process_signin(self,auth_id):
return redirect(auth_link)

def GET(self, auth_id):
if self.user_manager.session_cookieless():
if self.user_manager.session_is_lti():
return redirect("/auth/signin/" + auth_id)
return self.process_signin(auth_id)

Expand All @@ -47,7 +47,7 @@ def process_callback(self, auth_id):
return redirect(auth_storage.get("redir_url", "/"))

def GET(self, auth_id):
if self.user_manager.session_cookieless():
if self.user_manager.session_is_lti():
return redirect("/auth/signin/" + auth_id)
return self.process_callback(auth_id)

Expand Down
6 changes: 3 additions & 3 deletions inginious/frontend/pages/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def _pre_check(self):
""" Checks for language. """
if "lang" in flask.request.args and flask.request.args["lang"] in self.app.l10n_manager.translations.keys():
self.user_manager.set_session_language(flask.request.args["lang"])
elif "language" not in flask.session:
elif not self.user_manager.session_language(default=None):
best_lang = flask.request.accept_languages.best_match(self.app.l10n_manager.translations.keys(),
default="en")
self.user_manager.set_session_language(best_lang)
Expand Down Expand Up @@ -193,7 +193,7 @@ def GET(self, *args, **kwargs):
and not self.__class__.__name__ == "ProfilePage":
return redirect("/preferences/profile")

if not self.is_lti_page and self.user_manager.session_lti_info() is not None: # lti session
if not self.is_lti_page and self.user_manager.session_lti_info() is not None: # lti session, TODO(mp): Not sure whether it is still needed
self.user_manager.disconnect_user()
return self.template_helper.render("auth.html", auth_methods=self.user_manager.get_auth_methods())

Expand All @@ -220,7 +220,7 @@ def POST(self, *args, **kwargs):
if not self.user_manager.session_username() and not self.__class__.__name__ == "ProfilePage":
return redirect("/preferences/profile")

if not self.is_lti_page and self.user_manager.session_lti_info() is not None: # lti session
if not self.is_lti_page and self.user_manager.session_lti_info() is not None: # lti session, TODO(mp): Not sure whether it is still needed
self.user_manager.disconnect_user()
return self.template_helper.render("auth.html", auth_methods=self.user_manager.get_auth_methods())

Expand Down
Loading

0 comments on commit 262f9fd

Please sign in to comment.