Skip to content

Commit

Permalink
Add remote user authentication (asreview#1941)
Browse files Browse the repository at this point in the history
  • Loading branch information
dometto authored Jan 11, 2025
1 parent 6f4c294 commit fbf0516
Show file tree
Hide file tree
Showing 12 changed files with 411 additions and 86 deletions.
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,12 @@ When using or developing the authenticated version of ASReview, extra steps
are needed to configure the application.

Create an authentication config file as instructed in [Authentication]
(#Authentication). Set the environment variable `FLASK_CONFIGFILE` to the
(#Authentication). Set the environment variable `ASREVIEW_LAB_CONFIG_PATH` to the
local config file. Start the application again (If Flask app it still running, terminate first)

```sh
cd asreview/webapp
FLASK_CONFIGFILE=my_config.toml flask run --debug
ASREVIEW_LAB_CONFIG_PATH=my_config.toml flask run --debug
```

The server will read the file and start the authenticated version.
Expand Down
84 changes: 12 additions & 72 deletions asreview/webapp/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,92 +13,31 @@
# limitations under the License.


import datetime as dt
from pathlib import Path

from flask import Blueprint
from flask import current_app
from flask import jsonify
from flask import render_template_string
from flask import request
from flask_login import current_user
from flask_login import login_required
from flask_login import login_user
from flask_login import logout_user
from flask_mail import Mail
from flask_mail import Message

from sqlalchemy import and_
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import SQLAlchemyError
from asreview.webapp.authentication.decorators import login_remote_user

from asreview.webapp import DB
from asreview.webapp.authentication.models import User
from asreview.webapp.authentication.oauth_handler import OAuthHandler

bp = Blueprint("auth", __name__, url_prefix="/auth")
from asreview.webapp.authentication.utils import has_email_configuration
from asreview.webapp.authentication.utils import perform_login_user
from asreview.webapp.authentication.utils import send_forgot_password_email
from asreview.webapp.authentication.utils import send_confirm_account_email


def _has_email_configuration(app):
return all(
[
app.config.get("MAIL_SERVER", False),
app.config.get("MAIL_USERNAME", False),
app.config.get("MAIL_PASSWORD", False),
]
)


def perform_login_user(user):
"""Helper function to login a user"""
return login_user(user, remember=True, duration=dt.timedelta(days=31))


# TODO: not sure if this file is the right place for this function
def send_forgot_password_email(user, cur_app):
# do not send email in test environment
if not cur_app.testing:
# set name
name = user.name or "ASReview user"
# create a mailer
mailer = Mail(cur_app)
# open templates as string and render
root_path = Path(cur_app.root_path)
with open(root_path / "templates" / "emails" / "forgot_password.html") as f:
html_text = render_template_string(f.read(), name=name, token=user.token)
with open(root_path / "templates" / "emails" / "forgot_password.txt") as f:
txt_text = render_template_string(f.read(), name=name, token=user.token)
# create message
msg = Message("ASReview: forgot password", recipients=[user.email])
msg.body = txt_text
msg.html = html_text
return mailer.send(msg)


# TODO: not sure if this file is the right place for this function
def send_confirm_account_email(user, cur_app, email_type="create"):
# do not send email in test environment
if not cur_app.testing:
# get necessary information out of user object
name = user.name or "ASReview user"
# create a mailer
mailer = Mail(cur_app)
# open templates as string and render
root_path = Path(cur_app.root_path)
with open(root_path / "templates" / "emails" / "confirm_account.html") as f:
html_text = render_template_string(
f.read(), name=name, token=user.token, email_type=email_type
)
with open(root_path / "templates" / "emails" / "confirm_account.txt") as f:
txt_text = render_template_string(
f.read(), name=name, token=user.token, email_type=email_type
)
# create message
msg = Message("ASReview: please confirm your account", recipients=[user.email])
msg.body = txt_text
msg.html = html_text
return mailer.send(msg)

bp = Blueprint("auth", __name__, url_prefix="/auth")

# ------------------
# ROUTES
Expand All @@ -124,7 +63,7 @@ def signin():
else:
# user exists and is confirmed: verify password
if user.verify_password(password):
if perform_login_user(user):
if perform_login_user(user, current_app):
result = (
200,
{
Expand Down Expand Up @@ -300,7 +239,7 @@ def get_profile():
@bp.route("/forgot_password", methods=["POST"])
def forgot_password():
user_id = None
if _has_email_configuration(current_app):
if has_email_configuration(current_app):
# get email address from request
email_address = request.form.get("email", "").strip()

Expand Down Expand Up @@ -340,7 +279,7 @@ def forgot_password():
@bp.route("/reset_password", methods=["POST"])
def reset_password():
"""Resests password of user"""
if _has_email_configuration(current_app):
if has_email_configuration(current_app):
new_password = request.form.get("password", "").strip()
token = request.form.get("token", "").strip()
user_id = request.form.get("user_id", "0").strip()
Expand Down Expand Up @@ -433,6 +372,7 @@ def update_profile():


@bp.route("/user", methods=["GET"])
@login_remote_user
@login_required
def user():
return jsonify({"name": current_user.get_name(), "id": current_user.id}), 200
Expand Down Expand Up @@ -508,7 +448,7 @@ def oauth_callback():
return jsonify({"message": message}), 409

# log in the existing/created user immediately
logged_in = perform_login_user(user)
logged_in = perform_login_user(user, current_app)
result = (
200,
{
Expand Down
31 changes: 19 additions & 12 deletions asreview/webapp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
from flask import request
from flask import send_from_directory
from flask import redirect
from flask.json import jsonify
from flask.templating import render_template
from flask_cors import CORS
from flask_login import LoginManager
Expand All @@ -40,7 +39,9 @@
from asreview.webapp.api import team
from asreview.webapp.authentication.models import User
from asreview.webapp.authentication.oauth_handler import OAuthHandler
from asreview.webapp.authentication.remote_user_handler import RemoteUserHandler
from asreview.webapp.utils import asreview_path
from asreview.webapp.authentication.decorators import login_remote_user


def create_app(config_path=None):
Expand Down Expand Up @@ -84,6 +85,8 @@ def create_app(config_path=None):
login_manager.init_app(app)
login_manager.session_protection = "strong"

app.config["LOGIN_DURATION"] = int(app.config.get("LOGIN_DURATION", 31))

# Register a callback function for current_user.
@login_manager.user_loader
def load_user(user_id):
Expand All @@ -101,10 +104,11 @@ def load_user(user_id):
with app.app_context():
# create tables in case they don't exist
DB.create_all()

# store oauth config in oauth handler
if bool(app.config.get("OAUTH", False)):
app.config["OAUTH"] = OAuthHandler(app.config["OAUTH"])
if bool(app.config.get("REMOTE_USER", False)):
app.config["REMOTE_USER"] = RemoteUserHandler(app.config["REMOTE_USER"])

with app.app_context():
app.register_blueprint(auth.bp)
Expand All @@ -118,16 +122,18 @@ def load_user(user_id):

@app.errorhandler(InternalServerError)
def error_500(e):
original = getattr(e, "original_exception", None)

if original is None:
# direct 500 error, such as abort(500)
logging.error(e)
return jsonify(message="Whoops, something went wrong."), 500

# wrapped unhandled error
logging.error(e.original_exception)
return jsonify(message=str(e.original_exception)), 500
"""Return JSON instead of HTML for HTTP errors."""
response = e.get_response()
response.data = json.dumps(
{
"code": e.code,
"name": e.name,
"description": e.description,
}
)
logging.error(e.description)
response.content_type = "application/json"
return response

@app.route("/signin", methods=["GET"])
@app.route("/oauth_callback", methods=["GET"])
Expand All @@ -153,6 +159,7 @@ def index(**kwargs):

@app.route("/", methods=["GET"])
@app.route("/<path:url>", methods=["GET"])
@login_remote_user
def index_protected(**kwargs):
if (
not app.config.get("LOGIN_DISABLED", False)
Expand Down
51 changes: 51 additions & 0 deletions asreview/webapp/authentication/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,24 @@

from flask import current_app
from flask import jsonify
from flask import request
from flask import abort
from flask_login import current_user

import asreview as asr
from asreview.project.exceptions import ProjectNotFoundError
from asreview.project.api import is_project
from asreview.webapp.authentication.models import Project
from asreview.webapp.authentication.models import User
from asreview.webapp import DB
from asreview.webapp.utils import get_project_path
from asreview.webapp.utils import get_projects
from asreview.webapp.authentication.remote_user_handler import RemoteUserHandler
from asreview.webapp.authentication.utils import perform_login_user

from sqlalchemy.exc import IntegrityError, SQLAlchemyError

from werkzeug.exceptions import HTTPException


def project_authorization(f):
Expand Down Expand Up @@ -81,3 +91,44 @@ def decorated_function(*args, **kwargs):
return f(projects, *args, **kwargs)

return decorated_function


def login_remote_user(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if (
not current_app.config.get("LOGIN_DISABLED", False)
and not current_user.is_authenticated
):
remote_user_handler = current_app.config.get("REMOTE_USER", False)

if isinstance(remote_user_handler, RemoteUserHandler):
try:
user_info = remote_user_handler.handle_request(request.environ)
except HTTPException as e:
return jsonify({"message": e.description}), 401

if user_info["identifier"]:
user = User.query.filter(
User.identifier == user_info["identifier"]
).one_or_none()
if not user:
try:
user = User(
**user_info,
origin="remote",
public=True,
confirmed=True,
)
DB.session.add(user)
DB.session.commit()
except (IntegrityError, SQLAlchemyError):
DB.session.rollback()
abort(
500,
description="Error attempting to create user based on remote user authentication.",
)
perform_login_user(user, current_app)
return f(*args, **kwargs)

return decorated_function
70 changes: 70 additions & 0 deletions asreview/webapp/authentication/remote_user_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Copyright 2019-2022 The ASReview Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from werkzeug.exceptions import HTTPException


class RemoteUserNotAllowed(HTTPException):
code = 401
description = "Attempted to authenticate a remote user, but the REMOTE_AUTH_SECRET did not match."


class RemoteUserHandler:
default_headers = {
"USER_IDENTIFIER_HEADER": "REMOTE_USER",
"USER_NAME_HEADER": False,
"USER_EMAIL_HEADER": False,
"USER_AFFILIATION_HEADER": False,
"DEFAULT_EMAIL_DOMAIN": "localhost",
"DEFAULT_AFFILIATION": None,
}

def __init__(self, config={}):
for key, value in self.__class__.default_headers.items():
self.__dict__[key.lower()] = config.get(key, value)

self.remote_auth_secret = config.get("REMOTE_AUTH_SECRET", None)

def handle_request(self, env_headers):
"""Check the request"s environment headers and extract the configured headers,
falling back to the use of default values."""

if self.remote_auth_secret and not (
self.remote_auth_secret == env_headers.get("REMOTE_AUTH_SECRET", False)
):
raise RemoteUserNotAllowed

identifier = env_headers.get(self.user_identifier_header, "")
identifier_parts = identifier.split("@")
username = identifier_parts[
0
] # if identifier is not an email address, this will be the whole identifier

email = env_headers.get(self.user_email_header, False)
# if email was not explicitly set:
# check if identifier contained an "@", and use it as email address
# else create email using the username and default email domain
if not email and len(identifier_parts) > 1:
email = identifier
elif not email:
email = f"{username}@{self.default_email_domain}"

return {
"identifier": identifier if identifier else None,
"name": env_headers.get(self.user_name_header, username),
"email": email,
"affiliation": env_headers.get(
self.user_affiliation_header, self.default_affiliation
),
}
Loading

0 comments on commit fbf0516

Please sign in to comment.