IEEE.org     |     IEEE Xplore Digital Library     |     IEEE Standards     |     IEEE Spectrum     |     More Sites

Verified Commit 55baa8cf authored by Emi Simpson's avatar Emi Simpson
Browse files

Pull out SAML components to a pluggable auth module

parent cf52cee4
"""
A collection of different authentication modules
A single auth module can be used at once. Any authentication scheme that can perform all
of the tasks requested by the :class:`AuthModule` can be used.
If you are trying to deploy Mystic, and are looking for help configuring a specific
authentication module, look for your authentication module in the sidebar and read the
documentation for the class and the `__init__` method.
"""
from pymysql.cursors import Cursor
from mystic.database import User
from typing import Optional
from abc import ABC
from flask import redirect
from flask.app import Flask
class AuthModule(ABC):
"""
The superclass of all authentication modules
To implement a new authentication module, subclass this class and implement ALL of its
methods. Refer to the docs of each method for more information about implementation.
Note that your class may store any config information internally, and may even accept
arbitrary arguments in the constructor, so long as these are documented.
"""
def setup(self, app: Flask) -> None:
"""
Perform any setup on the module.
This is called exactly once per application startup. HOWEVER, this does not
necessarily correspond to once per instantiation, so individual instances
shouldn't count on having :func:`setup` called.
This method does not *need* to be implemented, but it's highly recommended. The
default behavior is to take no action.
This method will always be the first method of the class to be called, so other
methods can assume that this method has been called at least once, just not on
this specific instance.
"""
pass
def get_login_url(self) -> str:
"""
Get a URL that users should visit to log in
This could be a relative URL within the app, or the URL of a completely different
service. Either way, the flow should end at the root off the application.
The authentication module will have an opportunity to retrieve any information
that might be sent to the client during parts of the flow that occur on other
pages during a call to :func:`check_user`
"""
raise NotImplementedError
def check_user(self, c: Cursor) -> Optional[User]:
"""
Check if a user is logged in
This is called on most pages. If using an auth module that uses callback URLs to
pass information to the server, then this method should be used to check for the
presence of this information.
If this information is found, then the :func:`update_user` should be
called. Finally if a user was discovered, or an existing session was found in the
cache, that user should be returned, or `None` otherwise.
"""
raise NotImplementedError
def force_login(self, c: Cursor) -> User:
"""
Redirect the user to the login page if not logged in
Acts like :func:`check_user` if the user is logged in
Must be called in an app context.
This is implemented by default, and implementors may, but do not need to,
override.
"""
usr = self.check_user(c)
if usr is not None:
return usr
else:
redirect(self.get_login_url())
raise AssertionError("Unreachable")
def update_user(
cursor: Cursor,
user_id: int,
username: str,
first_name: str,
last_name: str,
email: str,
) -> User:
"""
Add a user to the database.
Fields are as follows:
- user_id: An integer permanently associated with this user. Will be exposed, so
don't use anything sensitive or private
- username: A short string unique to the user. Used by other users to look up
this user
- first_name: The user's first name, used for display purposes. If legal name
differs, prefer/ask the user's actual name
- last_name: The user's current last name
- email: The user's email. Outside of auth modules, currently only used for
libravatar support
"""
return User.create_or_update(
cursor,
user_id,
username,
first_name,
last_name,
email,
)
from mystic.database import User
from typing import Optional
from pymysql.cursors import Cursor
from mystic.auth import AuthModule, update_user
from flask.app import Flask
from flask.helpers import url_for
from flask_saml2.sp.sp import ServiceProvider
class MysticServiceProvider(ServiceProvider):
def get_default_login_return_url(self) -> str:
return url_for('main.main')
def get_logout_return_url(self) -> str:
return url_for('main.main')
class SamlAuth(AuthModule):
"""
An authenticator that uses a SAML IDP to identify the user
"""
def __init__(
self,
):
"""
This doesn't take any specific parameters, but instead expects to be able to find
a set of saml-specific parameters in the Flask config. For more information, see
https://flask-saml2.readthedocs.io/en/latest/sp/configuration.html
"""
self.service_provider = MysticServiceProvider()
def setup(self, app: Flask) -> None:
app.register_blueprint(self.service_provider.create_blueprint(), url_prefix='/saml/')
def get_login_url(self) -> str:
return self.service_provider.get_login_url()
def check_user(self, c: Cursor) -> Optional[User]:
if self.service_provider.is_user_logged_in():
user_info = self.service_provider.get_auth_data_in_session().attributes
user_id = int(user_info['uidNumber'])
return update_user(
c,
user_id,
user_info['uid'],
user_info['givenName'],
user_info['sn'],
user_info['mail']
)
else:
return None
......@@ -16,12 +16,12 @@ A more complete list of responsibilities:
- Exposing UUID utility
- Adding a list of accepted source types
- Configuring the application hooks
- Providing the SAML service provider object
"""
from __future__ import annotations
from base64 import b64encode
from hashlib import sha1
import json
from mystic.auth import AuthModule
from mystic.error_flagging import flag_errors_daemon
from threading import Thread
from mystic.projects import produce_project_report
......@@ -35,7 +35,6 @@ from pymysql.connections import Connection
from pymysql.cursors import Cursor
from flask import Flask, url_for, current_app, session, g
from flask.wrappers import Response
from flask_saml2.sp import ServiceProvider
from typing import Any, Dict, Optional, cast
import uuid
......@@ -126,6 +125,9 @@ def save_projects_json(c: Cursor, path: Optional[str] = None) -> None:
with open(path, 'w') as output_file:
json.dump(report, output_file, indent=2)
def get_auth_module() -> AuthModule:
return cast(AuthModule, current_app.config['AUTH'])
def create_app(test_config: Optional[Dict[str, Any]] = None) -> Flask:
app = Flask(__name__)
......@@ -142,7 +144,6 @@ def create_app(test_config: Optional[Dict[str, Any]] = None) -> Flask:
app.config.from_pyfile(config_path)
app.register_blueprint(views.bp)
app.register_blueprint(service_provider.create_blueprint(), url_prefix='/saml/')
app.jinja_env.globals.update(uuid=uuid.uuid4)
app.jinja_env.globals.update(high_contrast=is_high_contrast)
app.jinja_env.globals.update(static_versioned=static_versioned)
......@@ -152,6 +153,8 @@ def create_app(test_config: Optional[Dict[str, Any]] = None) -> Flask:
in SOURCE_PROCESSORS.items()
])
app.config['AUTH'].setup(app)
def configure_database() -> None:
try:
with get_database().cursor() as c:
......@@ -190,12 +193,3 @@ def create_app(test_config: Optional[Dict[str, Any]] = None) -> Flask:
app.teardown_appcontext(close_database)
return app
class MysticServiceProvider(ServiceProvider):
def get_default_login_return_url(self) -> str:
return url_for('main.main')
def get_logout_return_url(self) -> str:
return url_for('main.main')
service_provider = MysticServiceProvider()
......@@ -24,7 +24,7 @@ from pymysql.err import IntegrityError
from mystic.sources import SOURCE_PROCESSORS
from flask import request, session, g
from mystic.database import MalformedId, Project, User
from mystic.config import connect_coordinator, service_provider, save_projects_json
from mystic.config import connect_coordinator, get_auth_module, save_projects_json
from typing import Any, Callable, Coroutine, Dict, List, Optional, Set, Tuple, cast
class ErrorTuple(Exception):
......@@ -71,27 +71,14 @@ def get_user(cursor: Cursor) -> Optional[User]:
not properly set up
"""
if 'user' not in g:
if not service_provider.is_user_logged_in():
return None
user_info = service_provider.get_auth_data_in_session().attributes
g.user = User.create_or_update(
cursor,
int(user_info['uidNumber']),
user_info['uid'],
user_info['givenName'],
user_info['sn'],
user_info['mail'],
)
g.user = get_auth_module().check_user(cursor)
return cast(User, g.user)
def get_user_force_login(cursor: Cursor) -> User:
"""
Like :func:`get_user`, but instead of returning `None`, forces the user to log in
"""
service_provider.login_required()
user = get_user(cursor)
assert user is not None
return user
return get_auth_module().force_login(cursor)
def get_project(cursor: Cursor) -> Project:
"""
......
......@@ -6,12 +6,13 @@ connect up with.
'''
from functools import reduce
from elasticsearch.client import Elasticsearch
from pymysql.cursors import Cursor
from werkzeug.exceptions import BadRequest, NotFound
from mystic.database import MalformedId, NonexistantId, Project
from flask import json, render_template, Blueprint
from typing import Any, Dict, Tuple, cast
from mystic.config import get_database, get_elastic, service_provider
from mystic.config import get_auth_module, get_database, get_elastic
from mystic.frontend import get_analytics, get_many_pending_sources, get_pending_jobs, try_action, ErrorTuple, get_user
import flask
......@@ -40,20 +41,20 @@ def main() -> str:
if len(user.get_projects(cursor)) != 0:
return render_template(
"projects.html",
login=_login_or_profile(),
login=_login_or_profile(cursor),
user=user, cursor=cursor,
pending_sources=get_many_pending_sources(user.get_projects(cursor), cursor)
)
else:
return render_template(
"projects-empty.html",
login=_login_or_profile(),
login=_login_or_profile(cursor),
user=user, cursor=cursor
)
else:
return render_template(
"landing.html",
login=_login_or_profile(),
login=_login_or_profile(cursor),
cursor=cursor
)
......@@ -97,7 +98,7 @@ def view_project(pid: str) -> str:
return render_template(
"project.html",
project=project,
login=_login_or_profile(),
login=_login_or_profile(cursor),
user=user, cursor=cursor,
jobs=jobs,
dashboards=dashboards
......@@ -124,9 +125,10 @@ def post_main() -> str:
flask.flash(e.message, e.category)
return cast(str, main())
def _login_or_profile() -> Tuple[str, str]:
if service_provider.is_user_logged_in():
name = service_provider.get_auth_data_in_session().attributes['givenName']
return (name, '/account')
def _login_or_profile(c: Cursor) -> Tuple[str, str]:
auth_module = get_auth_module()
user = get_auth_module().check_user(c)
if user is not None:
return (user.get_first_name(c), '/account')
else:
return ('Login', service_provider.get_login_url())
return ('Login', auth_module.get_login_url())
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment