-
-
\ No newline at end of file
diff --git a/src/server/endpoints/__init__.py b/src/server/endpoints/__init__.py
index 94f1de5b8..1891a42c4 100644
--- a/src/server/endpoints/__init__.py
+++ b/src/server/endpoints/__init__.py
@@ -31,6 +31,8 @@
wiki,
signal_dashboard_status,
signal_dashboard_coverage,
+ registration,
+ api_key_removal_request
)
endpoints = [
@@ -66,6 +68,8 @@
wiki,
signal_dashboard_status,
signal_dashboard_coverage,
+ registration,
+ api_key_removal_request
]
__all__ = ["endpoints"]
diff --git a/src/server/endpoints/admin.py b/src/server/endpoints/admin.py
index a6f941b48..a71a3b297 100644
--- a/src/server/endpoints/admin.py
+++ b/src/server/endpoints/admin.py
@@ -1,135 +1,108 @@
-from pathlib import Path
-from typing import Dict, List, Set
+from datetime import timedelta
+from functools import wraps
-from flask import Blueprint, make_response, render_template_string, request
-from werkzeug.exceptions import NotFound, Unauthorized
-from werkzeug.utils import redirect
+from flask import session
+from flask_admin import Admin, AdminIndexView, expose
+from flask_admin.contrib.sqla import ModelView
+from werkzeug.exceptions import Unauthorized
-from .._common import log_info_with_request
-from .._config import ADMIN_PASSWORD, API_KEY_REGISTRATION_FORM_LINK, API_KEY_REMOVAL_REQUEST_LINK, REGISTER_WEBHOOK_TOKEN
+from .._common import app
+from .._config import ADMIN_PASSWORD
from .._db import WriteSession
from .._security import resolve_auth_token
from ..admin.models import User, UserRole
-self_dir = Path(__file__).parent
-# first argument is the endpoint name
-bp = Blueprint("admin", __name__)
+# set optional bootswatch theme
+app.config["FLASK_ADMIN_SWATCH"] = "cerulean"
+# set app secret key to enable session
+app.secret_key = "SOME_RANDOM_SECRET_KEY"
-templates_dir = Path(__file__).parent.parent / "admin" / "templates"
+def require_auth(func):
+ @wraps(func)
+ def check_token(*args, **kwargs):
+ # Check to see if it's in user's session
+ if "admin_auth_token" not in session:
+ raise Unauthorized()
+ return func(*args, **kwargs)
-def enable_admin() -> bool:
- # only enable admin endpoint if we have a password for it, so it is not exposed to the world
- return bool(ADMIN_PASSWORD)
+ return check_token
-def _require_admin():
+def require_admin():
token = resolve_auth_token()
if token is None or token != ADMIN_PASSWORD:
- raise Unauthorized()
- return token
-
-
-def _render(mode: str, token: str, flags: Dict, session, **kwargs):
- template = (templates_dir / "index.html").read_text("utf8")
- return render_template_string(
- template, mode=mode, token=token, flags=flags, roles=UserRole.list_all_roles(session), **kwargs
- )
-
-
-# ~~~~ PUBLIC ROUTES ~~~~
-
-
-@bp.route("/registration_form", methods=["GET"])
-def registration_form_redirect():
- # TODO: replace this with our own hosted registration form instead of external
- return redirect(API_KEY_REGISTRATION_FORM_LINK, code=302)
-
-
-@bp.route("/removal_request", methods=["GET"])
-def removal_request_redirect():
- # TODO: replace this with our own hosted form instead of external
- return redirect(API_KEY_REMOVAL_REQUEST_LINK, code=302)
-
-
-# ~~~~ PRIVLEGED ROUTES ~~~~
-
-
-@bp.route("/", methods=["GET", "POST"])
-def _index():
- token = _require_admin()
- flags = dict()
- with WriteSession() as session:
- if request.method == "POST":
- # register a new user
- if not User.find_user(
- user_email=request.values["email"], api_key=request.values["api_key"],
- session=session):
- User.create_user(
- api_key=request.values["api_key"],
- email=request.values["email"],
- user_roles=set(request.values.getlist("roles")),
- session=session
- )
- flags["banner"] = "Successfully Added"
- else:
- flags["banner"] = "User with such email and/or api key already exists."
- users = [user.as_dict for user in session.query(User).all()]
- return _render("overview", token, flags, session=session, users=users, user=dict())
-
-
-@bp.route("/", methods=["GET", "PUT", "POST", "DELETE"])
-def _detail(user_id: int):
- token = _require_admin()
- with WriteSession() as session:
- user = User.find_user(user_id=user_id, session=session)
- if not user:
- raise NotFound()
- if request.method == "DELETE" or "delete" in request.values:
- User.delete_user(user.id, session=session)
- return redirect(f"./?auth={token}")
- flags = dict()
- if request.method in ["PUT", "POST"]:
- user_check = User.find_user(api_key=request.values["api_key"], user_email=request.values["email"], session=session)
- if user_check and user_check.id != user.id:
- flags["banner"] = "Could not update user; same api_key and/or email already exists."
- else:
- user = User.update_user(
- user=user,
- api_key=request.values["api_key"],
- email=request.values["email"],
- roles=set(request.values.getlist("roles")),
- session=session
- )
- flags["banner"] = "Successfully Saved"
- return _render("detail", token, flags, session=session, user=user.as_dict)
-
-
-@bp.route("/register", methods=["POST"])
-def _register():
- body = request.get_json()
- token = body.get("token")
- if token is None or token != REGISTER_WEBHOOK_TOKEN:
- raise Unauthorized()
-
- user_api_key = body["user_api_key"]
- user_email = body["user_email"]
- with WriteSession() as session:
- if User.find_user(user_email=user_email, api_key=user_api_key, session=session):
- return make_response(
- "User with email and/or API Key already exists, use different parameters or contact us for help",
- 409,
- )
- User.create_user(api_key=user_api_key, email=user_email, session=session)
- return make_response(f"Successfully registered API key '{user_api_key}'", 200)
-
-
-@bp.route("/diagnostics", methods=["GET", "PUT", "POST", "DELETE"])
-def diags():
- # allows us to get useful diagnostic information written into server logs,
- # such as a full current "X-Forwarded-For" path as inserted into headers by intermediate proxies...
- # (but only when initiated purposefully by us to keep junk out of the logs)
- _require_admin()
- log_info_with_request("diagnostics", headers=request.headers)
- response_text = f"request path: {request.headers.get('X-Forwarded-For', 'idk')}"
- return make_response(response_text, 200, {'content-type': 'text/plain'})
+ if "admin_auth_token" not in session:
+ raise Unauthorized()
+ session["admin_auth_token"] = token
+
+
+class AuthModelView(ModelView):
+ @require_auth
+ def is_accessible(self):
+ return True
+
+
+@app.before_first_request # runs before FIRST request (only once)
+def make_session_permanent():
+ session.permanent = True
+ app.permanent_session_lifetime = timedelta(minutes=30)
+
+
+class AuthAdminIndexView(AdminIndexView):
+ """
+ Admin view main page
+ require_admin() is used for authentication using one of key words("auth", "api_key", "token") with ADMIN_PASSWORD
+ """
+
+ @expose("/")
+ def index(self):
+ require_admin()
+ return super().index()
+
+
+class UserView(AuthModelView):
+ """
+ User model view:
+ - form_columns: list of columns that will be available in CRUD forms
+ - column_list: list of columns that are displayed on user model view page
+ - column_filters: list of available filters
+ - page_size: number of items on page
+ """
+
+ form_columns = ["api_key", "email", "roles"]
+ column_list = ("api_key", "email", "created", "last_time_used", "roles")
+ column_filters = ("api_key", "email")
+
+ page_size = 10
+
+
+class UserRoleView(AuthModelView):
+ """
+ User role view:
+ - colums_filters: list of available filters
+ - page_size: number of items on page
+ """
+
+ column_filters = ["name"]
+
+ page_size = 10
+
+
+# init admin view, default endpoint is /admin
+admin = Admin(app, name="EpiData admin", template_mode="bootstrap4", index_view=AuthAdminIndexView())
+# database session
+admin_session = WriteSession()
+
+# add views
+admin.add_view(UserView(User, admin_session))
+admin.add_view(UserRoleView(UserRole, admin_session))
+
+
+@app.teardown_request
+def teardown_request(*args, **kwargs):
+ """
+ Remove the session after each request.
+ That is used to protect from dirty read.
+ """
+ admin_session.close()
diff --git a/src/server/endpoints/api_key_removal_request.py b/src/server/endpoints/api_key_removal_request.py
new file mode 100644
index 000000000..4c613b5ec
--- /dev/null
+++ b/src/server/endpoints/api_key_removal_request.py
@@ -0,0 +1,31 @@
+from flask import Blueprint, render_template, request
+
+from .._common import send_email
+from .._db import WriteSession
+from ..admin.models import RemovalRequest, User
+
+bp = Blueprint("removal_request", __name__, template_folder="/app/delphi/epidata/server/templates", static_url_path="")
+alias = None
+
+
+REMOVAL_REQUEST_MESSAGE = """
+Your API Key: {}, removal request will be processed soon.
+To verify, we will send you an email message after processing your request.
+Best,
+Delphi Team
+"""
+
+
+@bp.route("/", methods=["GET", "POST"])
+def handle():
+ flags = dict()
+ with WriteSession() as session:
+ if request.method == "POST":
+ api_key = request.values["api_key"]
+ comment = request.values.get("comment")
+ user = User.find_user(api_key=api_key)
+ # User.delete_user(user.id, session)
+ RemovalRequest.add_request(api_key, comment, session)
+ flags["banner"] = "Your request has been successfully recorded."
+ send_email(user.email, "API Key removal request", REMOVAL_REQUEST_MESSAGE.format(api_key))
+ return render_template("removal_request.html", flags=flags)
diff --git a/src/server/endpoints/registration.py b/src/server/endpoints/registration.py
new file mode 100644
index 000000000..cf72c6a49
--- /dev/null
+++ b/src/server/endpoints/registration.py
@@ -0,0 +1,45 @@
+import random
+import string
+
+from flask import Blueprint, render_template, request
+
+from .._common import send_email
+from .._db import WriteSession
+from ..admin.models import RegistrationResponse, User
+
+# first argument is the endpoint name
+bp = Blueprint("registration_form", __name__, template_folder="/app/delphi/epidata/server/templates", static_url_path="")
+alias = None
+
+NEW_KEY_MESSAGE = """
+Thank you for registering with the Delphi Epidata API.
+Your API key is: {}
+For usage information, see the API Keys section of the documentation: https://cmu-delphi.github.io/delphi-epidata/api/api_keys.html
+Best,
+Delphi Team
+"""
+
+
+@bp.route("/", methods=["GET", "POST"])
+def handle():
+ flags = dict()
+ with WriteSession() as session:
+ if request.method == "POST":
+ email = request.values["email"]
+ if not User.find_user(user_email=email):
+ # Use a separate table for email, purpose, organization
+ api_key = "".join(random.choices(string.ascii_letters + string.digits, k=13))
+ user = User.create_user(api_key=api_key, email=email, session=session)
+ RegistrationResponse.add_response(
+ email=email,
+ organization=request.values["organization"],
+ purpose=request.values["purpose"],
+ session=session,
+ )
+ send_email(
+ to_addr=email, subject="Delphi API Key Registration", message=NEW_KEY_MESSAGE.format(api_key)
+ )
+ flags["banner"] = "Successfully sent"
+ else:
+ flags["error"] = "User with such email already exists. Please try another email address or contact us."
+ return render_template("registration.html", flags=flags)
diff --git a/src/server/main.py b/src/server/main.py
index a91a91ee2..4b9bee2f0 100644
--- a/src/server/main.py
+++ b/src/server/main.py
@@ -2,16 +2,17 @@
import logging
from typing import Dict, Callable
-from flask import request, send_file, Response, send_from_directory, jsonify
+from flask import request, send_file, Response, send_from_directory, jsonify, make_response
from delphi.epidata.common.logger import get_structured_logger
from ._config import URL_PREFIX, VERSION
-from ._common import app, set_compatibility_mode
+from ._common import app, set_compatibility_mode, log_info_with_request
+from .endpoints.admin import require_admin
from ._exceptions import MissingOrWrongSourceException
from .endpoints import endpoints
-from .endpoints.admin import bp as admin_bp, enable_admin
-from ._limiter import limiter, apply_limit
+from .endpoints.admin import *
+from ._limiter import apply_limit
__all__ = ["app"]
@@ -30,12 +31,6 @@
logger.info("endpoint has alias", bp_name=endpoint.bp.name, alias=alias)
endpoint_map[alias] = endpoint.handle
-if enable_admin():
- logger.info("admin endpoint enabled")
- limiter.exempt(admin_bp)
- app.register_blueprint(admin_bp, url_prefix=f"{URL_PREFIX}/admin")
-
-
@app.route(f"{URL_PREFIX}/api.php", methods=["GET", "POST"])
@apply_limit
def handle_generic():
@@ -63,6 +58,21 @@ def send_lib_file(path: str):
return send_from_directory(pathlib.Path(__file__).parent / "lib", path)
+@app.route('/static/')
+def send_static(path):
+ return send_from_directory("/app/delphi/epidata/server/static", path)
+
+@app.route(f"{URL_PREFIX}/diagnostics", methods=["GET", "PUT", "POST", "DELETE"])
+def diags():
+ # allows us to get useful diagnostic information written into server logs,
+ # such as a full current "X-Forwarded-For" path as inserted into headers by intermediate proxies...
+ # (but only when initiated purposefully by us to keep junk out of the logs)
+ require_admin()
+ log_info_with_request("diagnostics", headers=request.headers)
+ response_text = f"request path: {request.headers.get('X-Forwarded-For', 'idk')}"
+ return make_response(response_text, 200, {"content-type": "text/plain"})
+
+
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
else:
diff --git a/src/server/static/css/style.css b/src/server/static/css/style.css
new file mode 100644
index 000000000..26ba2862b
--- /dev/null
+++ b/src/server/static/css/style.css
@@ -0,0 +1,41 @@
+.divider-text {
+ position: relative;
+ text-align: center;
+ margin-top: 15px;
+ margin-bottom: 15px;
+}
+
+.divider-text span {
+ padding: 7px;
+ font-size: 12px;
+ position: relative;
+ z-index: 2;
+}
+
+.divider-text:after {
+ content: "";
+ position: absolute;
+ width: 100%;
+ border-bottom: 1px solid #ddd;
+ top: 55%;
+ left: 0;
+ z-index: 1;
+}
+
+.btn-facebook {
+ background-color: #405D9D;
+ color: #fff;
+}
+
+.btn-twitter {
+ background-color: #42AEEC;
+ color: #fff;
+}
+
+.text-description {
+ padding: 3%;
+}
+
+.card-body {
+ width: 100%;
+}
\ No newline at end of file
diff --git a/src/server/templates/registration.html b/src/server/templates/registration.html
new file mode 100644
index 000000000..826099040
--- /dev/null
+++ b/src/server/templates/registration.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+ API Keys registration page
+
+
+
+
+
+
+
+
+
+
+
Delphi: Register API Key
+ {% if flags.banner %}
+
+ {{ flags.banner }}
+
+ {% endif %}
+ {% if flags.error %}
+
+ {{ flags.error }}
+
+ {% endif %}
+
This form allows you to register your API usage in order to lift the
+ restrictions
+ applied to anonymous access. If you regularly or frequently use our system, please consider using an
+ API key even if your usage falls within the anonymous usage limits. API key usage helps us
+ understand who and how others are using our Delphi Epidata API, which may in turn inform our future
+ research, data partnerships, and funding.
+
+ It is important for us to be able to contact our high-traffic users in case of excessive or abnormal
+ activity that may adversely affect our systems.
+
+ The questions about your organization and use case are optional, but we hope you will answer in
+ detail to help us better understand who and how others are using our API.
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/server/templates/removal_request.html b/src/server/templates/removal_request.html
new file mode 100644
index 000000000..67d1c8aaa
--- /dev/null
+++ b/src/server/templates/removal_request.html
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+ API Key removal request page
+
+
+
+
+
+
+
+
+
+
Delphi: API Key removal request
+ {% if flags.banner %}
+
+ {{ flags.banner }}
+
+ {% endif %}
+
+ Submit this form if you would like Delphi to disable your API key and destroy all information
+ associating that key with your identity. We will confirm the request by emailing the address you used at
+ registration time. Since this is a destructive operation, we handle these requests manually; you should
+ expect a response within 1-2 business days.
+
+
+
+
+
+
+
+
+
\ No newline at end of file
From a9899d114631a1e4c790d65234a156a4f86a7a5b Mon Sep 17 00:00:00 2001
From: Dmytro Trotsko
Date: Tue, 22 Aug 2023 22:58:08 +0300
Subject: [PATCH 2/3] Added login page. Changed admin authentication
---
requirements.api.txt | 1 +
src/server/_config.py | 4 +-
src/server/endpoints/admin.py | 71 ++++++++++++++++++--------
src/server/endpoints/registration.py | 3 +-
src/server/main.py | 4 +-
src/server/templates/login.html | 36 +++++++++++++
src/server/templates/registration.html | 8 ++-
7 files changed, 101 insertions(+), 26 deletions(-)
create mode 100644 src/server/templates/login.html
diff --git a/requirements.api.txt b/requirements.api.txt
index 0d5a123cf..f18f1ec61 100644
--- a/requirements.api.txt
+++ b/requirements.api.txt
@@ -2,6 +2,7 @@ delphi_utils==0.3.15
epiweeks==2.1.2
Flask==2.2.2
Flask-Admin==1.6.1
+Flask-Login==0.6.2
Flask-Limiter==3.3.0
itsdangerous<2.1
jinja2==3.0.3
diff --git a/src/server/_config.py b/src/server/_config.py
index a0914e04f..6ea9a6723 100644
--- a/src/server/_config.py
+++ b/src/server/_config.py
@@ -115,4 +115,6 @@
# STMP credentials
SMTP_HOST = "relay.andrew.cmu.edu"
SMTP_PORT = 25
-EMAIL_FROM = "noreply@andrew.cmu.edu"
\ No newline at end of file
+EMAIL_FROM = "noreply@andrew.cmu.edu"
+
+RECAPTCHA_SITE_KEY = os.environ.get("RECAPTCHA_SITE_KEY")
diff --git a/src/server/endpoints/admin.py b/src/server/endpoints/admin.py
index a71a3b297..75775459c 100644
--- a/src/server/endpoints/admin.py
+++ b/src/server/endpoints/admin.py
@@ -1,8 +1,8 @@
from datetime import timedelta
-from functools import wraps
-from flask import session
-from flask_admin import Admin, AdminIndexView, expose
+from flask import session, request, redirect, url_for, render_template
+import flask_login
+from flask_admin import Admin, AdminIndexView, expose, BaseView
from flask_admin.contrib.sqla import ModelView
from werkzeug.exceptions import Unauthorized
@@ -17,28 +17,51 @@
# set app secret key to enable session
app.secret_key = "SOME_RANDOM_SECRET_KEY"
+login_manager = flask_login.LoginManager()
+login_manager.login_view = "login"
+login_manager.init_app(app)
-def require_auth(func):
- @wraps(func)
- def check_token(*args, **kwargs):
- # Check to see if it's in user's session
- if "admin_auth_token" not in session:
- raise Unauthorized()
- return func(*args, **kwargs)
- return check_token
-
-
-def require_admin():
+def _require_admin():
token = resolve_auth_token()
if token is None or token != ADMIN_PASSWORD:
- if "admin_auth_token" not in session:
- raise Unauthorized()
- session["admin_auth_token"] = token
+ raise Unauthorized()
+ return token
+
+
+class AdminUser(flask_login.UserMixin):
+ pass
+
+
+@login_manager.user_loader
+def user_loader(admin_token):
+ if admin_token != ADMIN_PASSWORD:
+ return
+
+ user = AdminUser()
+ user.id = admin_token
+ return user
+
+
+@login_manager.unauthorized_handler
+def unauthorized_handler():
+ return "Unauthorized", 401
+
+
+@app.route("/login", methods=["GET", "POST"])
+def login():
+ if request.method == "POST":
+ admin_token = request.form["admin_token"]
+ if admin_token == ADMIN_PASSWORD:
+ user = AdminUser()
+ user.id = admin_token
+ flask_login.login_user(user)
+ return redirect(url_for("admin.index"))
+ return render_template("login.html")
class AuthModelView(ModelView):
- @require_auth
+ @flask_login.login_required
def is_accessible(self):
return True
@@ -52,12 +75,11 @@ def make_session_permanent():
class AuthAdminIndexView(AdminIndexView):
"""
Admin view main page
- require_admin() is used for authentication using one of key words("auth", "api_key", "token") with ADMIN_PASSWORD
"""
@expose("/")
+ @flask_login.login_required
def index(self):
- require_admin()
return super().index()
@@ -89,6 +111,14 @@ class UserRoleView(AuthModelView):
page_size = 10
+class LogoutView(BaseView):
+ @expose("/")
+ @flask_login.login_required
+ def logout(self):
+ flask_login.logout_user()
+ return redirect(url_for("login"))
+
+
# init admin view, default endpoint is /admin
admin = Admin(app, name="EpiData admin", template_mode="bootstrap4", index_view=AuthAdminIndexView())
# database session
@@ -97,6 +127,7 @@ class UserRoleView(AuthModelView):
# add views
admin.add_view(UserView(User, admin_session))
admin.add_view(UserRoleView(UserRole, admin_session))
+admin.add_view(LogoutView(name="Logout", endpoint="logout"))
@app.teardown_request
diff --git a/src/server/endpoints/registration.py b/src/server/endpoints/registration.py
index cf72c6a49..2af3999d0 100644
--- a/src/server/endpoints/registration.py
+++ b/src/server/endpoints/registration.py
@@ -6,6 +6,7 @@
from .._common import send_email
from .._db import WriteSession
from ..admin.models import RegistrationResponse, User
+from .._config import RECAPTCHA_SITE_KEY
# first argument is the endpoint name
bp = Blueprint("registration_form", __name__, template_folder="/app/delphi/epidata/server/templates", static_url_path="")
@@ -42,4 +43,4 @@ def handle():
flags["banner"] = "Successfully sent"
else:
flags["error"] = "User with such email already exists. Please try another email address or contact us."
- return render_template("registration.html", flags=flags)
+ return render_template("registration.html", flags=flags, recaptcha_key=RECAPTCHA_SITE_KEY)
diff --git a/src/server/main.py b/src/server/main.py
index 4b9bee2f0..e178e7997 100644
--- a/src/server/main.py
+++ b/src/server/main.py
@@ -8,7 +8,7 @@
from ._config import URL_PREFIX, VERSION
from ._common import app, set_compatibility_mode, log_info_with_request
-from .endpoints.admin import require_admin
+from .endpoints.admin import _require_admin
from ._exceptions import MissingOrWrongSourceException
from .endpoints import endpoints
from .endpoints.admin import *
@@ -67,7 +67,7 @@ def diags():
# allows us to get useful diagnostic information written into server logs,
# such as a full current "X-Forwarded-For" path as inserted into headers by intermediate proxies...
# (but only when initiated purposefully by us to keep junk out of the logs)
- require_admin()
+ _require_admin()
log_info_with_request("diagnostics", headers=request.headers)
response_text = f"request path: {request.headers.get('X-Forwarded-For', 'idk')}"
return make_response(response_text, 200, {"content-type": "text/plain"})
diff --git a/src/server/templates/login.html b/src/server/templates/login.html
new file mode 100644
index 000000000..3996ae0aa
--- /dev/null
+++ b/src/server/templates/login.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+ API Keys registration page
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/server/templates/registration.html b/src/server/templates/registration.html
index 826099040..cd7c36405 100644
--- a/src/server/templates/registration.html
+++ b/src/server/templates/registration.html
@@ -7,6 +7,7 @@
API Keys registration page
+
@@ -41,7 +42,7 @@
Delphi: Register API Key
detail to help us better understand who and how others are using our API.