diff --git a/cps/MyLoginManager.py b/cps/MyLoginManager.py
index c4025819f..5568ba91a 100644
--- a/cps/MyLoginManager.py
+++ b/cps/MyLoginManager.py
@@ -21,10 +21,10 @@
# along with this program. If not, see .
-from flask_login import LoginManager, confirm_login
+from .cw_login import LoginManager, confirm_login
from flask import session, current_app
-from flask_login.utils import decode_cookie
-from flask_login.signals import user_loaded_from_cookie
+from .cw_login.utils import decode_cookie
+from .cw_login.signals import user_loaded_from_cookie
class MyLoginManager(LoginManager):
@@ -43,7 +43,7 @@ class MyLoginManager(LoginManager):
session["_fresh"] = False
user = None
if self._user_callback:
- user = self._user_callback(user_id)
+ user = self._user_callback(user_id, None, None)
if user is not None:
app = current_app._get_current_object()
user_loaded_from_cookie.send(app, user=user)
@@ -51,3 +51,4 @@ class MyLoginManager(LoginManager):
confirm_login()
return user
return None
+
diff --git a/cps/__init__.py b/cps/__init__.py
index fead0d2f4..57832f791 100755
--- a/cps/__init__.py
+++ b/cps/__init__.py
@@ -83,8 +83,8 @@ log = logger.create()
app = Flask(__name__)
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
- SESSION_COOKIE_SAMESITE='Lax',
- REMEMBER_COOKIE_SAMESITE='Lax', # will be available in flask-login 0.5.1 earliest
+ SESSION_COOKIE_SAMESITE='Strict',
+ REMEMBER_COOKIE_SAMESITE='Strict', # will be available in flask-login 0.5.1 earliest
WTF_CSRF_SSL_STRICT=False
)
diff --git a/cps/about.py b/cps/about.py
index 1d081fe27..355978c3b 100644
--- a/cps/about.py
+++ b/cps/about.py
@@ -26,12 +26,12 @@ import sqlite3
from collections import OrderedDict
import flask
-import flask_login
import jinja2
from flask_babel import gettext as _
from . import db, calibre_db, converter, uploader, constants, dep_check
from .render_template import render_title_template
+from .usermanagement import user_login_required
about = flask.Blueprint('about', __name__)
@@ -74,7 +74,7 @@ def collect_stats():
@about.route("/stats")
-@flask_login.login_required
+@user_login_required
def stats():
counter = calibre_db.session.query(db.Books).count()
authors = calibre_db.session.query(db.Authors).count()
diff --git a/cps/admin.py b/cps/admin.py
index f5b9e6c3d..5f481fa18 100755
--- a/cps/admin.py
+++ b/cps/admin.py
@@ -34,10 +34,9 @@ from urllib.parse import urlparse
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
from markupsafe import Markup
-from flask_login import login_required, current_user, logout_user
+from .cw_login import current_user
from flask_babel import gettext as _
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
-from flask import session as flask_session
from sqlalchemy import and_
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError
@@ -52,6 +51,7 @@ from .embed_helper import get_calibre_binarypath
from .gdriveutils import is_gdrive_ready, gdrive_support
from .render_template import render_title_template, get_sidebar_config
from .services.worker import WorkerThread
+from .usermanagement import user_login_required
from .babel import get_available_translations, get_available_locale, get_user_locale_language
from . import debug_info
@@ -103,13 +103,13 @@ def admin_required(f):
@admi.before_app_request
def before_request():
- try:
- if not ub.check_user_session(current_user.id,
- flask_session.get('_id')) and 'opds' not in request.path \
- and config.config_session == 1:
- logout_user()
- except AttributeError:
- pass # ? fails on requesting /ajax/emailstat during restart ?
+ #try:
+ #if not ub.check_user_session(current_user.id,
+ # flask_session.get('_id')) and 'opds' not in request.path \
+ # and config.config_session == 1:
+ # logout_user()
+ #except AttributeError:
+ # pass # ? fails on requesting /ajax/emailstat during restart ?
g.constants = constants
g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '')
g.allow_registration = config.config_public_reg
@@ -129,14 +129,14 @@ def before_request():
return redirect(url_for('admin.db_configuration'))
-@admi.route("/admin")
-@login_required
-def admin_forbidden():
- abort(403)
+#@admi.route("/admin")
+#@user_login_required
+#def admin_forbidden():
+# abort(403)
@admi.route("/shutdown", methods=["POST"])
-@login_required
+@user_login_required
@admin_required
def shutdown():
task = request.get_json().get('parameter', -1)
@@ -165,7 +165,7 @@ def shutdown():
@admi.route("/metadata_backup", methods=["POST"])
-@login_required
+@user_login_required
@admin_required
def queue_metadata_backup():
show_text = {}
@@ -189,7 +189,7 @@ def reconnect():
@admi.route("/ajax/updateThumbnails", methods=['POST'])
@admin_required
-@login_required
+@user_login_required
def update_thumbnails():
content = config.get_scheduled_task_settings()
if content['schedule_generate_book_covers']:
@@ -199,7 +199,7 @@ def update_thumbnails():
@admi.route("/admin/view")
-@login_required
+@user_login_required
@admin_required
def admin():
version = updater_thread.get_current_version_info()
@@ -233,7 +233,7 @@ def admin():
@admi.route("/admin/dbconfig", methods=["GET", "POST"])
-@login_required
+@user_login_required
@admin_required
def db_configuration():
if request.method == "POST":
@@ -242,7 +242,7 @@ def db_configuration():
@admi.route("/admin/config", methods=["GET"])
-@login_required
+@user_login_required
@admin_required
def configuration():
return render_title_template("config_edit.html",
@@ -253,28 +253,28 @@ def configuration():
@admi.route("/admin/ajaxconfig", methods=["POST"])
-@login_required
+@user_login_required
@admin_required
def ajax_config():
return _configuration_update_helper()
@admi.route("/admin/ajaxdbconfig", methods=["POST"])
-@login_required
+@user_login_required
@admin_required
def ajax_db_config():
return _db_configuration_update_helper()
@admi.route("/admin/alive", methods=["GET"])
-@login_required
+@user_login_required
@admin_required
def calibreweb_alive():
return "", 200
@admi.route("/admin/viewconfig")
-@login_required
+@user_login_required
@admin_required
def view_configuration():
read_column = calibre_db.session.query(db.CustomColumns) \
@@ -291,7 +291,7 @@ def view_configuration():
@admi.route("/admin/usertable")
-@login_required
+@user_login_required
@admin_required
def edit_user_table():
visibility = current_user.view_settings.get('useredit', {})
@@ -326,7 +326,7 @@ def edit_user_table():
@admi.route("/ajax/listusers")
-@login_required
+@user_login_required
@admin_required
def list_users():
off = int(request.args.get("offset") or 0)
@@ -377,7 +377,7 @@ def list_users():
@admi.route("/ajax/deleteuser", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def delete_user():
user_ids = request.form.to_dict(flat=False)
@@ -412,7 +412,7 @@ def delete_user():
@admi.route("/ajax/getlocale")
-@login_required
+@user_login_required
@admin_required
def table_get_locale():
locale = get_available_locale()
@@ -424,7 +424,7 @@ def table_get_locale():
@admi.route("/ajax/getdefaultlanguage")
-@login_required
+@user_login_required
@admin_required
def table_get_default_lang():
languages = calibre_db.speaking_language()
@@ -436,7 +436,7 @@ def table_get_default_lang():
@admi.route("/ajax/editlistusers/", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def edit_list_user(param):
vals = request.form.to_dict(flat=False)
@@ -541,7 +541,7 @@ def edit_list_user(param):
@admi.route("/ajax/user_table_settings", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def update_table_settings():
current_user.view_settings['useredit'] = json.loads(request.data)
@@ -558,7 +558,7 @@ def update_table_settings():
@admi.route("/admin/viewconfig", methods=["POST"])
-@login_required
+@user_login_required
@admin_required
def update_view_configuration():
to_save = request.form.to_dict()
@@ -603,7 +603,7 @@ def update_view_configuration():
@admi.route("/ajax/loaddialogtexts/", methods=['POST'])
-@login_required
+@user_login_required
def load_dialogtexts(element_id):
texts = {"header": "", "main": "", "valid": 1}
if element_id == "config_delete_kobo_token":
@@ -639,7 +639,7 @@ def load_dialogtexts(element_id):
@admi.route("/ajax/editdomain/", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def edit_domain(allow):
# POST /post
@@ -653,7 +653,7 @@ def edit_domain(allow):
@admi.route("/ajax/adddomain/", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def add_domain(allow):
domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower()
@@ -667,7 +667,7 @@ def add_domain(allow):
@admi.route("/ajax/deletedomain", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def delete_domain():
try:
@@ -685,7 +685,7 @@ def delete_domain():
@admi.route("/ajax/domainlist/")
-@login_required
+@user_login_required
@admin_required
def list_domain(allow):
answer = ub.session.query(ub.Registration).filter(ub.Registration.allow == allow).all()
@@ -698,7 +698,7 @@ def list_domain(allow):
@admi.route("/ajax/editrestriction/", defaults={"user_id": 0}, methods=['POST'])
@admi.route("/ajax/editrestriction//", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def edit_restriction(res_type, user_id):
element = request.form.to_dict()
@@ -764,14 +764,14 @@ def edit_restriction(res_type, user_id):
@admi.route("/ajax/addrestriction/", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def add_user_0_restriction(res_type):
return add_restriction(res_type, 0)
@admi.route("/ajax/addrestriction//", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def add_restriction(res_type, user_id):
element = request.form.to_dict()
@@ -817,14 +817,14 @@ def add_restriction(res_type, user_id):
@admi.route("/ajax/deleterestriction/", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def delete_user_0_restriction(res_type):
return delete_restriction(res_type, 0)
@admi.route("/ajax/deleterestriction//", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def delete_restriction(res_type, user_id):
element = request.form.to_dict()
@@ -872,7 +872,7 @@ def delete_restriction(res_type, user_id):
@admi.route("/ajax/listrestriction/", defaults={"user_id": 0})
@admi.route("/ajax/listrestriction//")
-@login_required
+@user_login_required
@admin_required
def list_restriction(res_type, user_id):
if res_type == 0: # Tags as template
@@ -916,20 +916,20 @@ def list_restriction(res_type, user_id):
@admi.route("/ajax/fullsync", methods=["POST"])
-@login_required
+@user_login_required
def ajax_self_fullsync():
return do_full_kobo_sync(current_user.id)
@admi.route("/ajax/fullsync/", methods=["POST"])
-@login_required
+@user_login_required
@admin_required
def ajax_fullsync(userid):
return do_full_kobo_sync(userid)
@admi.route("/ajax/pathchooser/")
-@login_required
+@user_login_required
@admin_required
def ajax_pathchooser():
return pathchooser()
@@ -1246,7 +1246,7 @@ def _configuration_ldap_helper(to_save):
@admi.route("/ajax/simulatedbchange", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def simulatedbchange():
db_change, db_valid = _db_simulate_change()
@@ -1254,7 +1254,7 @@ def simulatedbchange():
@admi.route("/admin/user/new", methods=["GET", "POST"])
-@login_required
+@user_login_required
@admin_required
def new_user():
content = ub.User()
@@ -1276,7 +1276,7 @@ def new_user():
@admi.route("/admin/mailsettings", methods=["GET"])
-@login_required
+@user_login_required
@admin_required
def edit_mailsettings():
content = config.get_mail_settings()
@@ -1285,7 +1285,7 @@ def edit_mailsettings():
@admi.route("/admin/mailsettings", methods=["POST"])
-@login_required
+@user_login_required
@admin_required
def update_mailsettings():
to_save = request.form.to_dict()
@@ -1342,7 +1342,7 @@ def update_mailsettings():
@admi.route("/admin/scheduledtasks")
-@login_required
+@user_login_required
@admin_required
def edit_scheduledtasks():
content = config.get_scheduled_task_settings()
@@ -1363,7 +1363,7 @@ def edit_scheduledtasks():
@admi.route("/admin/scheduledtasks", methods=["POST"])
-@login_required
+@user_login_required
@admin_required
def update_scheduledtasks():
error = False
@@ -1406,7 +1406,7 @@ def update_scheduledtasks():
@admi.route("/admin/user/", methods=["GET", "POST"])
-@login_required
+@user_login_required
@admin_required
def edit_user(user_id):
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
@@ -1435,7 +1435,7 @@ def edit_user(user_id):
@admi.route("/admin/resetpassword/", methods=["POST"])
-@login_required
+@user_login_required
@admin_required
def reset_user_password(user_id):
if current_user is not None and current_user.is_authenticated:
@@ -1453,7 +1453,7 @@ def reset_user_password(user_id):
@admi.route("/admin/logfile")
-@login_required
+@user_login_required
@admin_required
def view_logfile():
logfiles = {0: logger.get_logfile(config.config_logfile),
@@ -1467,7 +1467,7 @@ def view_logfile():
@admi.route("/ajax/log/")
-@login_required
+@user_login_required
@admin_required
def send_logfile(logtype):
if logtype == 1:
@@ -1483,7 +1483,7 @@ def send_logfile(logtype):
@admi.route("/admin/logdownload/")
-@login_required
+@user_login_required
@admin_required
def download_log(logtype):
if logtype == 0:
@@ -1498,14 +1498,14 @@ def download_log(logtype):
@admi.route("/admin/debug")
-@login_required
+@user_login_required
@admin_required
def download_debug():
return debug_info.send_debug()
@admi.route("/get_update_status", methods=['GET'])
-@login_required
+@user_login_required
@admin_required
def get_update_status():
if feature_support['updater']:
@@ -1516,7 +1516,7 @@ def get_update_status():
@admi.route("/get_updater_status", methods=['GET', 'POST'])
-@login_required
+@user_login_required
@admin_required
def get_updater_status():
status = {}
@@ -1611,7 +1611,7 @@ def ldap_import_create_user(user, user_data):
@admi.route('/import_ldap_users', methods=["POST"])
-@login_required
+@user_login_required
@admin_required
def import_ldap_users():
showtext = {}
@@ -1666,7 +1666,7 @@ def import_ldap_users():
@admi.route("/ajax/canceltask", methods=['POST'])
-@login_required
+@user_login_required
@admin_required
def cancel_task():
task_id = request.get_json().get('task_id', None)
diff --git a/cps/babel.py b/cps/babel.py
index 21ed4fc76..cf043d528 100644
--- a/cps/babel.py
+++ b/cps/babel.py
@@ -2,7 +2,7 @@ from babel import negotiate_locale
from flask_babel import Babel, Locale
from babel.core import UnknownLocaleError
from flask import request
-from flask_login import current_user
+from .cw_login import current_user
from . import logger
diff --git a/cps/cw_login/__init__.py b/cps/cw_login/__init__.py
new file mode 100644
index 000000000..f2822baaf
--- /dev/null
+++ b/cps/cw_login/__init__.py
@@ -0,0 +1,98 @@
+# from .__about__ import __version__
+from .config import AUTH_HEADER_NAME
+from .config import COOKIE_DURATION
+from .config import COOKIE_HTTPONLY
+from .config import COOKIE_NAME
+from .config import COOKIE_SECURE
+from .config import ID_ATTRIBUTE
+from .config import LOGIN_MESSAGE
+from .config import LOGIN_MESSAGE_CATEGORY
+from .config import REFRESH_MESSAGE
+from .config import REFRESH_MESSAGE_CATEGORY
+from .login_manager import LoginManager
+from .mixins import AnonymousUserMixin
+from .mixins import UserMixin
+from .signals import session_protected
+from .signals import user_accessed
+from .signals import user_loaded_from_cookie
+from .signals import user_loaded_from_request
+from .signals import user_logged_in
+from .signals import user_logged_out
+from .signals import user_login_confirmed
+from .signals import user_needs_refresh
+from .signals import user_unauthorized
+# from .test_client import FlaskLoginClient
+from .utils import confirm_login
+from .utils import current_user
+from .utils import decode_cookie
+from .utils import encode_cookie
+from .utils import fresh_login_required
+from .utils import login_fresh
+from .utils import login_remembered
+from .utils import login_required
+from .utils import login_url
+from .utils import login_user
+from .utils import logout_user
+from .utils import make_next_param
+from .utils import set_login_view
+
+__version_info__ = ("0", "6", "3")
+__version__ = ".".join(__version_info__)
+
+
+__all__ = [
+ "__version__",
+ "AUTH_HEADER_NAME",
+ "COOKIE_DURATION",
+ "COOKIE_HTTPONLY",
+ "COOKIE_NAME",
+ "COOKIE_SECURE",
+ "ID_ATTRIBUTE",
+ "LOGIN_MESSAGE",
+ "LOGIN_MESSAGE_CATEGORY",
+ "REFRESH_MESSAGE",
+ "REFRESH_MESSAGE_CATEGORY",
+ "LoginManager",
+ "AnonymousUserMixin",
+ "UserMixin",
+ "session_protected",
+ "user_accessed",
+ "user_loaded_from_cookie",
+ "user_loaded_from_request",
+ "user_logged_in",
+ "user_logged_out",
+ "user_login_confirmed",
+ "user_needs_refresh",
+ "user_unauthorized",
+ # "FlaskLoginClient",
+ "confirm_login",
+ "current_user",
+ "decode_cookie",
+ "encode_cookie",
+ "fresh_login_required",
+ "login_fresh",
+ "login_remembered",
+ "login_required",
+ "login_url",
+ "login_user",
+ "logout_user",
+ "make_next_param",
+ "set_login_view",
+]
+
+
+def __getattr__(name):
+ if name == "user_loaded_from_header":
+ import warnings
+ from .signals import _user_loaded_from_header
+
+ warnings.warn(
+ "'user_loaded_from_header' is deprecated and will be"
+ " removed in Flask-Login 0.7. Use"
+ " 'user_loaded_from_request' instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return _user_loaded_from_header
+
+ raise AttributeError(name)
diff --git a/cps/cw_login/config.py b/cps/cw_login/config.py
new file mode 100644
index 000000000..fe2db2c5c
--- /dev/null
+++ b/cps/cw_login/config.py
@@ -0,0 +1,55 @@
+from datetime import timedelta
+
+#: The default name of the "remember me" cookie (``remember_token``)
+COOKIE_NAME = "remember_token"
+
+#: The default time before the "remember me" cookie expires (365 days).
+COOKIE_DURATION = timedelta(days=365)
+
+#: Whether the "remember me" cookie requires Secure; defaults to ``False``
+COOKIE_SECURE = False
+
+#: Whether the "remember me" cookie uses HttpOnly or not; defaults to ``True``
+COOKIE_HTTPONLY = True
+
+#: Whether the "remember me" cookie requires same origin; defaults to ``None``
+COOKIE_SAMESITE = None
+
+#: The default flash message to display when users need to log in.
+LOGIN_MESSAGE = "Please log in to access this page."
+
+#: The default flash message category to display when users need to log in.
+LOGIN_MESSAGE_CATEGORY = "message"
+
+#: The default flash message to display when users need to reauthenticate.
+REFRESH_MESSAGE = "Please reauthenticate to access this page."
+
+#: The default flash message category to display when users need to
+#: reauthenticate.
+REFRESH_MESSAGE_CATEGORY = "message"
+
+#: The default attribute to retreive the str id of the user
+ID_ATTRIBUTE = "get_id"
+
+#: Default name of the auth header (``Authorization``)
+AUTH_HEADER_NAME = "Authorization"
+
+#: A set of session keys that are populated by Flask-Login. Use this set to
+#: purge keys safely and accurately.
+SESSION_KEYS = {
+ "_user_id",
+ "_remember",
+ "_remember_seconds",
+ "_id",
+ "_fresh",
+ "next",
+}
+
+#: A set of HTTP methods which are exempt from `login_required` and
+#: `fresh_login_required`. By default, this is just ``OPTIONS``.
+EXEMPT_METHODS = {"OPTIONS"}
+
+#: If true, the page the user is attempting to access is stored in the session
+#: rather than a url parameter when redirecting to the login view; defaults to
+#: ``False``.
+USE_SESSION_FOR_NEXT = False
diff --git a/cps/cw_login/login_manager.py b/cps/cw_login/login_manager.py
new file mode 100644
index 000000000..9149f722b
--- /dev/null
+++ b/cps/cw_login/login_manager.py
@@ -0,0 +1,525 @@
+from datetime import datetime
+from datetime import timedelta
+
+from flask import abort
+from flask import current_app
+from flask import flash
+from flask import g
+from flask import has_app_context
+from flask import redirect
+from flask import request
+from flask import session
+
+from .config import AUTH_HEADER_NAME
+from .config import COOKIE_DURATION
+from .config import COOKIE_HTTPONLY
+from .config import COOKIE_NAME
+from .config import COOKIE_SAMESITE
+from .config import COOKIE_SECURE
+from .config import ID_ATTRIBUTE
+from .config import LOGIN_MESSAGE
+from .config import LOGIN_MESSAGE_CATEGORY
+from .config import REFRESH_MESSAGE
+from .config import REFRESH_MESSAGE_CATEGORY
+from .config import SESSION_KEYS
+from .config import USE_SESSION_FOR_NEXT
+from .mixins import AnonymousUserMixin
+from .signals import session_protected
+from .signals import user_accessed
+from .signals import user_loaded_from_cookie
+from .signals import user_loaded_from_request
+from .signals import user_needs_refresh
+from .signals import user_unauthorized
+from .utils import _create_identifier
+from .utils import _user_context_processor
+from .utils import decode_cookie
+from .utils import encode_cookie
+from .utils import expand_login_view
+from .utils import login_url as make_login_url
+from .utils import make_next_param
+
+
+class LoginManager:
+ """This object is used to hold the settings used for logging in. Instances
+ of :class:`LoginManager` are *not* bound to specific apps, so you can
+ create one in the main body of your code and then bind it to your
+ app in a factory function.
+ """
+
+ def __init__(self, app=None, add_context_processor=True):
+ #: A class or factory function that produces an anonymous user, which
+ #: is used when no one is logged in.
+ self.anonymous_user = AnonymousUserMixin
+
+ #: The name of the view to redirect to when the user needs to log in.
+ #: (This can be an absolute URL as well, if your authentication
+ #: machinery is external to your application.)
+ self.login_view = None
+
+ #: Names of views to redirect to when the user needs to log in,
+ #: per blueprint. If the key value is set to None the value of
+ #: :attr:`login_view` will be used instead.
+ self.blueprint_login_views = {}
+
+ #: The message to flash when a user is redirected to the login page.
+ self.login_message = LOGIN_MESSAGE
+
+ #: The message category to flash when a user is redirected to the login
+ #: page.
+ self.login_message_category = LOGIN_MESSAGE_CATEGORY
+
+ #: The name of the view to redirect to when the user needs to
+ #: reauthenticate.
+ self.refresh_view = None
+
+ #: The message to flash when a user is redirected to the 'needs
+ #: refresh' page.
+ self.needs_refresh_message = REFRESH_MESSAGE
+
+ #: The message category to flash when a user is redirected to the
+ #: 'needs refresh' page.
+ self.needs_refresh_message_category = REFRESH_MESSAGE_CATEGORY
+
+ #: The mode to use session protection in. This can be either
+ #: ``'basic'`` (the default) or ``'strong'``, or ``None`` to disable
+ #: it.
+ self.session_protection = "basic"
+
+ #: If present, used to translate flash messages ``self.login_message``
+ #: and ``self.needs_refresh_message``
+ self.localize_callback = None
+
+ self.unauthorized_callback = None
+
+ self.needs_refresh_callback = None
+
+ self.id_attribute = ID_ATTRIBUTE
+
+ self._user_callback = None
+
+ self._header_callback = None
+
+ self._request_callback = None
+
+ self._session_identifier_generator = _create_identifier
+
+ if app is not None:
+ self.init_app(app, add_context_processor)
+
+ def setup_app(self, app, add_context_processor=True): # pragma: no cover
+ """
+ This method has been deprecated. Please use
+ :meth:`LoginManager.init_app` instead.
+ """
+ import warnings
+
+ warnings.warn(
+ "'setup_app' is deprecated and will be removed in"
+ " Flask-Login 0.7. Use 'init_app' instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ self.init_app(app, add_context_processor)
+
+ def init_app(self, app, add_context_processor=True):
+ """
+ Configures an application. This registers an `after_request` call, and
+ attaches this `LoginManager` to it as `app.login_manager`.
+
+ :param app: The :class:`flask.Flask` object to configure.
+ :type app: :class:`flask.Flask`
+ :param add_context_processor: Whether to add a context processor to
+ the app that adds a `current_user` variable to the template.
+ Defaults to ``True``.
+ :type add_context_processor: bool
+ """
+ app.login_manager = self
+ app.after_request(self._update_remember_cookie)
+
+ if add_context_processor:
+ app.context_processor(_user_context_processor)
+
+ def unauthorized(self):
+ """
+ This is called when the user is required to log in. If you register a
+ callback with :meth:`LoginManager.unauthorized_handler`, then it will
+ be called. Otherwise, it will take the following actions:
+
+ - Flash :attr:`LoginManager.login_message` to the user.
+
+ - If the app is using blueprints find the login view for
+ the current blueprint using `blueprint_login_views`. If the app
+ is not using blueprints or the login view for the current
+ blueprint is not specified use the value of `login_view`.
+
+ - Redirect the user to the login view. (The page they were
+ attempting to access will be passed in the ``next`` query
+ string variable, so you can redirect there if present instead
+ of the homepage. Alternatively, it will be added to the session
+ as ``next`` if USE_SESSION_FOR_NEXT is set.)
+
+ If :attr:`LoginManager.login_view` is not defined, then it will simply
+ raise a HTTP 401 (Unauthorized) error instead.
+
+ This should be returned from a view or before/after_request function,
+ otherwise the redirect will have no effect.
+ """
+ user_unauthorized.send(current_app._get_current_object())
+
+ if self.unauthorized_callback:
+ return self.unauthorized_callback()
+
+ if request.blueprint in self.blueprint_login_views:
+ login_view = self.blueprint_login_views[request.blueprint]
+ else:
+ login_view = self.login_view
+
+ if not login_view:
+ abort(401)
+
+ if self.login_message:
+ if self.localize_callback is not None:
+ flash(
+ self.localize_callback(self.login_message),
+ category=self.login_message_category,
+ )
+ else:
+ flash(self.login_message, category=self.login_message_category)
+
+ config = current_app.config
+ if config.get("USE_SESSION_FOR_NEXT", USE_SESSION_FOR_NEXT):
+ login_url = expand_login_view(login_view)
+ session["_id"] = self._session_identifier_generator()
+ session["next"] = make_next_param(login_url, request.url)
+ redirect_url = make_login_url(login_view)
+ else:
+ redirect_url = make_login_url(login_view, next_url=request.url)
+
+ return redirect(redirect_url)
+
+ def user_loader(self, callback):
+ """
+ This sets the callback for reloading a user from the session. The
+ function you set should take a user ID (a ``str``) and return a
+ user object, or ``None`` if the user does not exist.
+
+ :param callback: The callback for retrieving a user object.
+ :type callback: callable
+ """
+ self._user_callback = callback
+ return self.user_callback
+
+ @property
+ def user_callback(self):
+ """Gets the user_loader callback set by user_loader decorator."""
+ return self._user_callback
+
+ def request_loader(self, callback):
+ """
+ This sets the callback for loading a user from a Flask request.
+ The function you set should take Flask request object and
+ return a user object, or `None` if the user does not exist.
+
+ :param callback: The callback for retrieving a user object.
+ :type callback: callable
+ """
+ self._request_callback = callback
+ return self.request_callback
+
+ @property
+ def request_callback(self):
+ """Gets the request_loader callback set by request_loader decorator."""
+ return self._request_callback
+
+ def unauthorized_handler(self, callback):
+ """
+ This will set the callback for the `unauthorized` method, which among
+ other things is used by `login_required`. It takes no arguments, and
+ should return a response to be sent to the user instead of their
+ normal view.
+
+ :param callback: The callback for unauthorized users.
+ :type callback: callable
+ """
+ self.unauthorized_callback = callback
+ return callback
+
+ def needs_refresh_handler(self, callback):
+ """
+ This will set the callback for the `needs_refresh` method, which among
+ other things is used by `fresh_login_required`. It takes no arguments,
+ and should return a response to be sent to the user instead of their
+ normal view.
+
+ :param callback: The callback for unauthorized users.
+ :type callback: callable
+ """
+ self.needs_refresh_callback = callback
+ return callback
+
+ def needs_refresh(self):
+ """
+ This is called when the user is logged in, but they need to be
+ reauthenticated because their session is stale. If you register a
+ callback with `needs_refresh_handler`, then it will be called.
+ Otherwise, it will take the following actions:
+
+ - Flash :attr:`LoginManager.needs_refresh_message` to the user.
+
+ - Redirect the user to :attr:`LoginManager.refresh_view`. (The page
+ they were attempting to access will be passed in the ``next``
+ query string variable, so you can redirect there if present
+ instead of the homepage.)
+
+ If :attr:`LoginManager.refresh_view` is not defined, then it will
+ simply raise a HTTP 401 (Unauthorized) error instead.
+
+ This should be returned from a view or before/after_request function,
+ otherwise the redirect will have no effect.
+ """
+ user_needs_refresh.send(current_app._get_current_object())
+
+ if self.needs_refresh_callback:
+ return self.needs_refresh_callback()
+
+ if not self.refresh_view:
+ abort(401)
+
+ if self.needs_refresh_message:
+ if self.localize_callback is not None:
+ flash(
+ self.localize_callback(self.needs_refresh_message),
+ category=self.needs_refresh_message_category,
+ )
+ else:
+ flash(
+ self.needs_refresh_message,
+ category=self.needs_refresh_message_category,
+ )
+
+ config = current_app.config
+ if config.get("USE_SESSION_FOR_NEXT", USE_SESSION_FOR_NEXT):
+ login_url = expand_login_view(self.refresh_view)
+ session["_id"] = self._session_identifier_generator()
+ session["next"] = make_next_param(login_url, request.url)
+ redirect_url = make_login_url(self.refresh_view)
+ else:
+ login_url = self.refresh_view
+ redirect_url = make_login_url(login_url, next_url=request.url)
+
+ return redirect(redirect_url)
+
+ def _update_request_context_with_user(self, user=None):
+ """Store the given user as ctx.user."""
+
+ if user is None:
+ user = self.anonymous_user()
+
+ g._login_user = user
+
+ def _load_user(self):
+ """Loads user from session or remember_me cookie as applicable"""
+
+ if self._user_callback is None and self._request_callback is None:
+ raise Exception(
+ "Missing user_loader or request_loader. Refer to "
+ "http://flask-login.readthedocs.io/#how-it-works "
+ "for more info."
+ )
+
+ user_accessed.send(current_app._get_current_object())
+
+ # Check SESSION_PROTECTION
+ if self._session_protection_failed():
+ return self._update_request_context_with_user()
+
+ user = None
+
+ # Load user from Flask Session
+ user_id = session.get("_user_id")
+ user_random = session.get("_random")
+ user_session_key = session.get("_id")
+ if (user_id is not None
+ and user_random is not None
+ and user_session_key is not None
+ and self._user_callback is not None):
+ user = self._user_callback(user_id, user_random, user_session_key)
+
+ # Load user from Remember Me Cookie or Request Loader
+ if user is None:
+ config = current_app.config
+ cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
+ header_name = config.get("AUTH_HEADER_NAME", AUTH_HEADER_NAME)
+ has_cookie = (
+ cookie_name in request.cookies and session.get("_remember") != "clear"
+ )
+ if has_cookie:
+ cookie = request.cookies[cookie_name]
+ user = self._load_user_from_remember_cookie(cookie)
+ elif self._request_callback:
+ user = self._load_user_from_request(request)
+ elif header_name in request.headers:
+ header = request.headers[header_name]
+ user = self._load_user_from_header(header)
+
+ return self._update_request_context_with_user(user)
+
+ def _session_protection_failed(self):
+ sess = session._get_current_object()
+ ident = self._session_identifier_generator()
+
+ app = current_app._get_current_object()
+ mode = app.config.get("SESSION_PROTECTION", self.session_protection)
+
+ if not mode or mode not in ["basic", "strong"]:
+ return False
+
+ # if the sess is empty, it's an anonymous user or just logged out
+ # so we can skip this
+ if sess and ident != sess.get("_id", None):
+ if mode == "basic" or sess.permanent:
+ if sess.get("_fresh") is not False:
+ sess["_fresh"] = False
+ session_protected.send(app)
+ return False
+ elif mode == "strong":
+ for k in SESSION_KEYS:
+ sess.pop(k, None)
+
+ sess["_remember"] = "clear"
+ session_protected.send(app)
+ return True
+
+ return False
+
+ def _load_user_from_remember_cookie(self, cookie):
+ user_id = decode_cookie(cookie)
+ if user_id is not None:
+ session["_user_id"] = user_id
+ session["_fresh"] = False
+ user = None
+ if self._user_callback:
+ user = self._user_callback(user_id)
+ if user is not None:
+ app = current_app._get_current_object()
+ user_loaded_from_cookie.send(app, user=user)
+ return user
+ return None
+
+ def _load_user_from_header(self, header):
+ if self._header_callback:
+ user = self._header_callback(header)
+ if user is not None:
+ app = current_app._get_current_object()
+
+ from .signals import _user_loaded_from_header
+
+ _user_loaded_from_header.send(app, user=user)
+ return user
+ return None
+
+ def _load_user_from_request(self, request):
+ if self._request_callback:
+ user = self._request_callback(request)
+ if user is not None:
+ app = current_app._get_current_object()
+ user_loaded_from_request.send(app, user=user)
+ return user
+ return None
+
+ def _update_remember_cookie(self, response):
+ # Don't modify the session unless there's something to do.
+ if "_remember" not in session and current_app.config.get(
+ "REMEMBER_COOKIE_REFRESH_EACH_REQUEST"
+ ):
+ session["_remember"] = "set"
+
+ if "_remember" in session:
+ operation = session.pop("_remember", None)
+
+ if operation == "set" and "_user_id" in session:
+ self._set_cookie(response)
+ elif operation == "clear":
+ self._clear_cookie(response)
+
+ return response
+
+ def _set_cookie(self, response):
+ # cookie settings
+ config = current_app.config
+ cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
+ domain = config.get("REMEMBER_COOKIE_DOMAIN")
+ path = config.get("REMEMBER_COOKIE_PATH", "/")
+
+ secure = config.get("REMEMBER_COOKIE_SECURE", COOKIE_SECURE)
+ httponly = config.get("REMEMBER_COOKIE_HTTPONLY", COOKIE_HTTPONLY)
+ samesite = config.get("REMEMBER_COOKIE_SAMESITE", COOKIE_SAMESITE)
+
+ if "_remember_seconds" in session:
+ duration = timedelta(seconds=session["_remember_seconds"])
+ else:
+ duration = config.get("REMEMBER_COOKIE_DURATION", COOKIE_DURATION)
+
+ # prepare data
+ data = encode_cookie(str(session["_user_id"]))
+
+ if isinstance(duration, int):
+ duration = timedelta(seconds=duration)
+
+ try:
+ expires = datetime.utcnow() + duration
+ except TypeError as e:
+ raise Exception(
+ "REMEMBER_COOKIE_DURATION must be a datetime.timedelta,"
+ f" instead got: {duration}"
+ ) from e
+
+ # actually set it
+ response.set_cookie(
+ cookie_name,
+ value=data,
+ expires=expires,
+ domain=domain,
+ path=path,
+ secure=secure,
+ httponly=httponly,
+ samesite=samesite,
+ )
+
+ def _clear_cookie(self, response):
+ config = current_app.config
+ cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
+ domain = config.get("REMEMBER_COOKIE_DOMAIN")
+ path = config.get("REMEMBER_COOKIE_PATH", "/")
+ response.delete_cookie(cookie_name, domain=domain, path=path)
+
+ @property
+ def _login_disabled(self):
+ """Legacy property, use app.config['LOGIN_DISABLED'] instead."""
+ import warnings
+
+ warnings.warn(
+ "'_login_disabled' is deprecated and will be removed in"
+ " Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
+ " instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+
+ if has_app_context():
+ return current_app.config.get("LOGIN_DISABLED", False)
+ return False
+
+ @_login_disabled.setter
+ def _login_disabled(self, newvalue):
+ """Legacy property setter, use app.config['LOGIN_DISABLED'] instead."""
+ import warnings
+
+ warnings.warn(
+ "'_login_disabled' is deprecated and will be removed in"
+ " Flask-Login 0.7. Use 'LOGIN_DISABLED' in 'app.config'"
+ " instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ current_app.config["LOGIN_DISABLED"] = newvalue
diff --git a/cps/cw_login/mixins.py b/cps/cw_login/mixins.py
new file mode 100644
index 000000000..0b3a71bbe
--- /dev/null
+++ b/cps/cw_login/mixins.py
@@ -0,0 +1,65 @@
+class UserMixin:
+ """
+ This provides default implementations for the methods that Flask-Login
+ expects user objects to have.
+ """
+
+ # Python 3 implicitly set __hash__ to None if we override __eq__
+ # We set it back to its default implementation
+ __hash__ = object.__hash__
+
+ @property
+ def is_active(self):
+ return True
+
+ @property
+ def is_authenticated(self):
+ return self.is_active
+
+ @property
+ def is_anonymous(self):
+ return False
+
+ def get_id(self):
+ try:
+ return str(self.id)
+ except AttributeError:
+ raise NotImplementedError("No `id` attribute - override `get_id`") from None
+
+ def __eq__(self, other):
+ """
+ Checks the equality of two `UserMixin` objects using `get_id`.
+ """
+ if isinstance(other, UserMixin):
+ return self.get_id() == other.get_id()
+ return NotImplemented
+
+ def __ne__(self, other):
+ """
+ Checks the inequality of two `UserMixin` objects using `get_id`.
+ """
+ equal = self.__eq__(other)
+ if equal is NotImplemented:
+ return NotImplemented
+ return not equal
+
+
+class AnonymousUserMixin:
+ """
+ This is the default object for representing an anonymous user.
+ """
+
+ @property
+ def is_authenticated(self):
+ return False
+
+ @property
+ def is_active(self):
+ return False
+
+ @property
+ def is_anonymous(self):
+ return True
+
+ def get_id(self):
+ return
diff --git a/cps/cw_login/signals.py b/cps/cw_login/signals.py
new file mode 100644
index 000000000..cf9157f8b
--- /dev/null
+++ b/cps/cw_login/signals.py
@@ -0,0 +1,61 @@
+from flask.signals import Namespace
+
+_signals = Namespace()
+
+#: Sent when a user is logged in. In addition to the app (which is the
+#: sender), it is passed `user`, which is the user being logged in.
+user_logged_in = _signals.signal("logged-in")
+
+#: Sent when a user is logged out. In addition to the app (which is the
+#: sender), it is passed `user`, which is the user being logged out.
+user_logged_out = _signals.signal("logged-out")
+
+#: Sent when the user is loaded from the cookie. In addition to the app (which
+#: is the sender), it is passed `user`, which is the user being reloaded.
+user_loaded_from_cookie = _signals.signal("loaded-from-cookie")
+
+#: Sent when the user is loaded from the header. In addition to the app (which
+#: is the #: sender), it is passed `user`, which is the user being reloaded.
+_user_loaded_from_header = _signals.signal("loaded-from-header")
+
+#: Sent when the user is loaded from the request. In addition to the app (which
+#: is the #: sender), it is passed `user`, which is the user being reloaded.
+user_loaded_from_request = _signals.signal("loaded-from-request")
+
+#: Sent when a user's login is confirmed, marking it as fresh. (It is not
+#: called for a normal login.)
+#: It receives no additional arguments besides the app.
+user_login_confirmed = _signals.signal("login-confirmed")
+
+#: Sent when the `unauthorized` method is called on a `LoginManager`. It
+#: receives no additional arguments besides the app.
+user_unauthorized = _signals.signal("unauthorized")
+
+#: Sent when the `needs_refresh` method is called on a `LoginManager`. It
+#: receives no additional arguments besides the app.
+user_needs_refresh = _signals.signal("needs-refresh")
+
+#: Sent whenever the user is accessed/loaded
+#: receives no additional arguments besides the app.
+user_accessed = _signals.signal("accessed")
+
+#: Sent whenever session protection takes effect, and a session is either
+#: marked non-fresh or deleted. It receives no additional arguments besides
+#: the app.
+session_protected = _signals.signal("session-protected")
+
+
+def __getattr__(name):
+ if name == "user_loaded_from_header":
+ import warnings
+
+ warnings.warn(
+ "'user_loaded_from_header' is deprecated and will be"
+ " removed in Flask-Login 0.7. Use"
+ " 'user_loaded_from_request' instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return _user_loaded_from_header
+
+ raise AttributeError(name)
diff --git a/cps/cw_login/utils.py b/cps/cw_login/utils.py
new file mode 100644
index 000000000..9f662f4f8
--- /dev/null
+++ b/cps/cw_login/utils.py
@@ -0,0 +1,424 @@
+import hmac
+import os
+from functools import wraps
+from hashlib import sha512
+from urllib.parse import parse_qs
+from urllib.parse import urlencode
+from urllib.parse import urlsplit
+from urllib.parse import urlunsplit
+
+from flask import current_app
+from flask import g
+from flask import has_request_context
+from flask import request
+from flask import session
+from flask import url_for
+from werkzeug.local import LocalProxy
+
+from .config import COOKIE_NAME
+from .config import EXEMPT_METHODS
+from .signals import user_logged_in
+from .signals import user_logged_out
+from .signals import user_login_confirmed
+
+#: A proxy for the current user. If no user is logged in, this will be an
+#: anonymous user
+current_user = LocalProxy(lambda: _get_user())
+
+
+def encode_cookie(payload, key=None):
+ """
+ This will encode a ``str`` value into a cookie, and sign that cookie
+ with the app's secret key.
+
+ :param payload: The value to encode, as `str`.
+ :type payload: str
+
+ :param key: The key to use when creating the cookie digest. If not
+ specified, the SECRET_KEY value from app config will be used.
+ :type key: str
+ """
+ return f"{payload}|{_cookie_digest(payload, key=key)}"
+
+
+def decode_cookie(cookie, key=None):
+ """
+ This decodes a cookie given by `encode_cookie`. If verification of the
+ cookie fails, ``None`` will be implicitly returned.
+
+ :param cookie: An encoded cookie.
+ :type cookie: str
+
+ :param key: The key to use when creating the cookie digest. If not
+ specified, the SECRET_KEY value from app config will be used.
+ :type key: str
+ """
+ try:
+ payload, digest = cookie.rsplit("|", 1)
+ if hasattr(digest, "decode"):
+ digest = digest.decode("ascii") # pragma: no cover
+ except ValueError:
+ return
+
+ if hmac.compare_digest(_cookie_digest(payload, key=key), digest):
+ return payload
+
+
+def make_next_param(login_url, current_url):
+ """
+ Reduces the scheme and host from a given URL so it can be passed to
+ the given `login` URL more efficiently.
+
+ :param login_url: The login URL being redirected to.
+ :type login_url: str
+ :param current_url: The URL to reduce.
+ :type current_url: str
+ """
+ l_url = urlsplit(login_url)
+ c_url = urlsplit(current_url)
+
+ if (not l_url.scheme or l_url.scheme == c_url.scheme) and (
+ not l_url.netloc or l_url.netloc == c_url.netloc
+ ):
+ return urlunsplit(("", "", c_url.path, c_url.query, ""))
+ return current_url
+
+
+def expand_login_view(login_view):
+ """
+ Returns the url for the login view, expanding the view name to a url if
+ needed.
+
+ :param login_view: The name of the login view or a URL for the login view.
+ :type login_view: str
+ """
+ if login_view.startswith(("https://", "http://", "/")):
+ return login_view
+
+ return url_for(login_view)
+
+
+def login_url(login_view, next_url=None, next_field="next"):
+ """
+ Creates a URL for redirecting to a login page. If only `login_view` is
+ provided, this will just return the URL for it. If `next_url` is provided,
+ however, this will append a ``next=URL`` parameter to the query string
+ so that the login view can redirect back to that URL. Flask-Login's default
+ unauthorized handler uses this function when redirecting to your login url.
+ To force the host name used, set `FORCE_HOST_FOR_REDIRECTS` to a host. This
+ prevents from redirecting to external sites if request headers Host or
+ X-Forwarded-For are present.
+
+ :param login_view: The name of the login view. (Alternately, the actual
+ URL to the login view.)
+ :type login_view: str
+ :param next_url: The URL to give the login view for redirection.
+ :type next_url: str
+ :param next_field: What field to store the next URL in. (It defaults to
+ ``next``.)
+ :type next_field: str
+ """
+ base = expand_login_view(login_view)
+
+ if next_url is None:
+ return base
+
+ parsed_result = urlsplit(base)
+ md = parse_qs(parsed_result.query, keep_blank_values=True)
+ md[next_field] = make_next_param(base, next_url)
+ netloc = current_app.config.get("FORCE_HOST_FOR_REDIRECTS") or parsed_result.netloc
+ parsed_result = parsed_result._replace(
+ netloc=netloc, query=urlencode(md, doseq=True)
+ )
+ return urlunsplit(parsed_result)
+
+
+def login_fresh():
+ """
+ This returns ``True`` if the current login is fresh.
+ """
+ return session.get("_fresh", False)
+
+
+def login_remembered():
+ """
+ This returns ``True`` if the current login is remembered across sessions.
+ """
+ config = current_app.config
+ cookie_name = config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
+ has_cookie = cookie_name in request.cookies and session.get("_remember") != "clear"
+ if has_cookie:
+ cookie = request.cookies[cookie_name]
+ user_id = decode_cookie(cookie)
+ return user_id is not None
+ return False
+
+
+def login_user(user, remember=False, duration=None, force=False, fresh=True):
+ """
+ Logs a user in. You should pass the actual user object to this. If the
+ user's `is_active` property is ``False``, they will not be logged in
+ unless `force` is ``True``.
+
+ This will return ``True`` if the log in attempt succeeds, and ``False`` if
+ it fails (i.e. because the user is inactive).
+
+ :param user: The user object to log in.
+ :type user: object
+ :param remember: Whether to remember the user after their session expires.
+ Defaults to ``False``.
+ :type remember: bool
+ :param duration: The amount of time before the remember cookie expires. If
+ ``None`` the value set in the settings is used. Defaults to ``None``.
+ :type duration: :class:`datetime.timedelta`
+ :param force: If the user is inactive, setting this to ``True`` will log
+ them in regardless. Defaults to ``False``.
+ :type force: bool
+ :param fresh: setting this to ``False`` will log in the user with a session
+ marked as not "fresh". Defaults to ``True``.
+ :type fresh: bool
+ """
+ if not force and not user.is_active:
+ return False
+
+ user_id = getattr(user, current_app.login_manager.id_attribute)()
+ session["_user_id"] = user_id
+ session["_fresh"] = fresh
+ session["_id"] = current_app.login_manager._session_identifier_generator()
+ session["_random"] = os.urandom(10)
+
+ if remember:
+ session["_remember"] = "set"
+ if duration is not None:
+ try:
+ # equal to timedelta.total_seconds() but works with Python 2.6
+ session["_remember_seconds"] = (
+ duration.microseconds
+ + (duration.seconds + duration.days * 24 * 3600) * 10**6
+ ) / 10.0**6
+ except AttributeError as e:
+ raise Exception(
+ f"duration must be a datetime.timedelta, instead got: {duration}"
+ ) from e
+
+ current_app.login_manager._update_request_context_with_user(user)
+ user_logged_in.send(current_app._get_current_object(), user=_get_user())
+ return True
+
+
+def logout_user():
+ """
+ Logs a user out. (You do not need to pass the actual user.) This will
+ also clean up the remember me cookie if it exists.
+ """
+
+ user = _get_user()
+
+ if "_user_id" in session:
+ session.pop("_user_id")
+
+ if "_fresh" in session:
+ session.pop("_fresh")
+
+ if "_id" in session:
+ session.pop("_id")
+
+ if "_random" in session:
+ session.pop("_random")
+
+
+ cookie_name = current_app.config.get("REMEMBER_COOKIE_NAME", COOKIE_NAME)
+ if cookie_name in request.cookies:
+ session["_remember"] = "clear"
+ if "_remember_seconds" in session:
+ session.pop("_remember_seconds")
+
+ user_logged_out.send(current_app._get_current_object(), user=user)
+
+ current_app.login_manager._update_request_context_with_user()
+ return True
+
+
+def confirm_login():
+ """
+ This sets the current session as fresh. Sessions become stale when they
+ are reloaded from a cookie.
+ """
+ session["_fresh"] = True
+ session["_id"] = current_app.login_manager._session_identifier_generator()
+ user_login_confirmed.send(current_app._get_current_object())
+
+
+def login_required(func):
+ """
+ If you decorate a view with this, it will ensure that the current user is
+ logged in and authenticated before calling the actual view. (If they are
+ not, it calls the :attr:`LoginManager.unauthorized` callback.) For
+ example::
+
+ @app.route('/post')
+ @user_login_required
+ def post():
+ pass
+
+ If there are only certain times you need to require that your user is
+ logged in, you can do so with::
+
+ if not current_user.is_authenticated:
+ return current_app.login_manager.unauthorized()
+
+ ...which is essentially the code that this function adds to your views.
+
+ It can be convenient to globally turn off authentication when unit testing.
+ To enable this, if the application configuration variable `LOGIN_DISABLED`
+ is set to `True`, this decorator will be ignored.
+
+ .. Note ::
+
+ Per `W3 guidelines for CORS preflight requests
+ `_,
+ HTTP ``OPTIONS`` requests are exempt from login checks.
+
+ :param func: The view function to decorate.
+ :type func: function
+ """
+
+ @wraps(func)
+ def decorated_view(*args, **kwargs):
+ if request.method in EXEMPT_METHODS or current_app.config.get("LOGIN_DISABLED"):
+ pass
+ elif not current_user.is_authenticated:
+ return current_app.login_manager.unauthorized()
+
+ # flask 1.x compatibility
+ # current_app.ensure_sync is only available in Flask >= 2.0
+ if callable(getattr(current_app, "ensure_sync", None)):
+ return current_app.ensure_sync(func)(*args, **kwargs)
+ return func(*args, **kwargs)
+
+ return decorated_view
+
+
+def fresh_login_required(func):
+ """
+ If you decorate a view with this, it will ensure that the current user's
+ login is fresh - i.e. their session was not restored from a 'remember me'
+ cookie. Sensitive operations, like changing a password or e-mail, should
+ be protected with this, to impede the efforts of cookie thieves.
+
+ If the user is not authenticated, :meth:`LoginManager.unauthorized` is
+ called as normal. If they are authenticated, but their session is not
+ fresh, it will call :meth:`LoginManager.needs_refresh` instead. (In that
+ case, you will need to provide a :attr:`LoginManager.refresh_view`.)
+
+ Behaves identically to the :func:`login_required` decorator with respect
+ to configuration variables.
+
+ .. Note ::
+
+ Per `W3 guidelines for CORS preflight requests
+ `_,
+ HTTP ``OPTIONS`` requests are exempt from login checks.
+
+ :param func: The view function to decorate.
+ :type func: function
+ """
+
+ @wraps(func)
+ def decorated_view(*args, **kwargs):
+ if request.method in EXEMPT_METHODS or current_app.config.get("LOGIN_DISABLED"):
+ pass
+ elif not current_user.is_authenticated:
+ return current_app.login_manager.unauthorized()
+ elif not login_fresh():
+ return current_app.login_manager.needs_refresh()
+ try:
+ # current_app.ensure_sync available in Flask >= 2.0
+ return current_app.ensure_sync(func)(*args, **kwargs)
+ except AttributeError: # pragma: no cover
+ return func(*args, **kwargs)
+
+ return decorated_view
+
+
+def set_login_view(login_view, blueprint=None):
+ """
+ Sets the login view for the app or blueprint. If a blueprint is passed,
+ the login view is set for this blueprint on ``blueprint_login_views``.
+
+ :param login_view: The user object to log in.
+ :type login_view: str
+ :param blueprint: The blueprint which this login view should be set on.
+ Defaults to ``None``.
+ :type blueprint: object
+ """
+
+ num_login_views = len(current_app.login_manager.blueprint_login_views)
+ if blueprint is not None or num_login_views != 0:
+ (current_app.login_manager.blueprint_login_views[blueprint.name]) = login_view
+
+ if (
+ current_app.login_manager.login_view is not None
+ and None not in current_app.login_manager.blueprint_login_views
+ ):
+ (
+ current_app.login_manager.blueprint_login_views[None]
+ ) = current_app.login_manager.login_view
+
+ current_app.login_manager.login_view = None
+ else:
+ current_app.login_manager.login_view = login_view
+
+
+def _get_user():
+ if has_request_context():
+ if "flask_httpauth_user" in g:
+ if g.flask_httpauth_user is not None:
+ return g.flask_httpauth_user
+ if "_login_user" not in g:
+ current_app.login_manager._load_user()
+
+ return g._login_user
+
+ return None
+
+
+def _cookie_digest(payload, key=None):
+ key = _secret_key(key)
+
+ return hmac.new(key, payload.encode("utf-8"), sha512).hexdigest()
+
+
+def _get_remote_addr():
+ address = request.headers.get("X-Forwarded-For", request.remote_addr)
+ if address is not None:
+ # An 'X-Forwarded-For' header includes a comma separated list of the
+ # addresses, the first address being the actual remote address.
+ address = address.encode("utf-8").split(b",")[0].strip()
+ return address
+
+
+def _create_identifier():
+ user_agent = request.headers.get("User-Agent")
+ if user_agent is not None:
+ user_agent = user_agent.encode("utf-8")
+ base = f"{_get_remote_addr()}|{user_agent}"
+ if str is bytes:
+ base = str(base, "utf-8", errors="replace") # pragma: no cover
+ h = sha512()
+ h.update(base.encode("utf8"))
+ return h.hexdigest()
+
+
+def _user_context_processor():
+ return dict(current_user=_get_user())
+
+
+def _secret_key(key=None):
+ if key is None:
+ key = current_app.config["SECRET_KEY"]
+
+ if isinstance(key, str): # pragma: no cover
+ key = key.encode("latin1") # ensure bytes
+
+ return key
diff --git a/cps/db.py b/cps/db.py
index 1770457b8..62b68b47a 100644
--- a/cps/db.py
+++ b/cps/db.py
@@ -23,6 +23,7 @@ import json
from datetime import datetime
from urllib.parse import quote
import unidecode
+from weakref import WeakSet
from sqlite3 import OperationalError as sqliteOperationalError
from sqlalchemy import create_engine
@@ -40,7 +41,7 @@ except ImportError:
from sqlalchemy.pool import StaticPool
from sqlalchemy.sql.expression import and_, true, false, text, func, or_
from sqlalchemy.ext.associationproxy import association_proxy
-from flask_login import current_user
+from .cw_login import current_user
from flask_babel import gettext as _
from flask_babel import get_locale
from flask import flash
@@ -48,8 +49,6 @@ from flask import flash
from . import logger, ub, isoLanguages
from .pagination import Pagination
-from weakref import WeakSet
-
log = logger.create()
diff --git a/cps/editbooks.py b/cps/editbooks.py
index 5e10d6edd..84299c63b 100644
--- a/cps/editbooks.py
+++ b/cps/editbooks.py
@@ -32,7 +32,7 @@ from flask import Blueprint, request, flash, redirect, url_for, abort, Response
from flask_babel import gettext as _
from flask_babel import lazy_gettext as N_
from flask_babel import get_locale
-from flask_login import current_user, login_required
+from .cw_login import current_user, login_required
from sqlalchemy.exc import OperationalError, IntegrityError, InterfaceError
from sqlalchemy.orm.exc import StaleDataError
from sqlalchemy.sql.expression import func
@@ -43,10 +43,11 @@ from . import config, ub, db, calibre_db
from .services.worker import WorkerThread
from .tasks.upload import TaskUpload
from .render_template import render_title_template
-from .usermanagement import login_required_if_no_ano
from .kobo_sync_status import change_archived_books
from .redirect import get_redirect_location
from .file_helper import validate_mime_type
+from .usermanagement import user_login_required, login_required_if_no_ano
+
editbook = Blueprint('edit-book', __name__)
log = logger.create()
@@ -73,14 +74,14 @@ def edit_required(f):
@editbook.route("/ajax/delete/", methods=["POST"])
-@login_required
+@user_login_required
def delete_book_from_details(book_id):
return Response(delete_book_from_table(book_id, "", True), mimetype='application/json')
@editbook.route("/delete/", defaults={'book_format': ""}, methods=["POST"])
@editbook.route("/delete//", methods=["POST"])
-@login_required
+@user_login_required
def delete_book_ajax(book_id, book_format):
return delete_book_from_table(book_id, book_format, False, request.form.to_dict().get('location', ""))
@@ -331,7 +332,7 @@ def convert_bookformat(book_id):
@editbook.route("/ajax/getcustomenum/")
-@login_required
+@user_login_required
def table_get_custom_enum(c_id):
ret = list()
cc = (calibre_db.session.query(db.CustomColumns)
@@ -455,7 +456,7 @@ def edit_list_book(param):
@editbook.route("/ajax/sort_value//")
-@login_required
+@user_login_required
def get_sorted_entry(field, bookid):
if field in ['title', 'authors', 'sort', 'author_sort']:
book = calibre_db.get_filtered_book(bookid)
@@ -472,7 +473,7 @@ def get_sorted_entry(field, bookid):
@editbook.route("/ajax/simulatemerge", methods=['POST'])
-@login_required
+@user_login_required
@edit_required
def simulate_merge_list_book():
vals = request.get_json().get('Merge_books')
@@ -488,7 +489,7 @@ def simulate_merge_list_book():
@editbook.route("/ajax/mergebooks", methods=['POST'])
-@login_required
+@user_login_required
@edit_required
def merge_list_book():
vals = request.get_json().get('Merge_books')
@@ -526,7 +527,7 @@ def merge_list_book():
@editbook.route("/ajax/xchange", methods=['POST'])
-@login_required
+@user_login_required
@edit_required
def table_xchange_author_title():
vals = request.get_json().get('xchange')
diff --git a/cps/gdrive.py b/cps/gdrive.py
index 284fb21e2..07795b51c 100644
--- a/cps/gdrive.py
+++ b/cps/gdrive.py
@@ -29,11 +29,11 @@ from shutil import move, copyfile
from flask import Blueprint, flash, request, redirect, url_for, abort
from flask_babel import gettext as _
-from flask_login import login_required
from . import logger, gdriveutils, config, ub, calibre_db, csrf
from .admin import admin_required
from .file_helper import get_temp_dir
+from .usermanagement import user_login_required
gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive')
log = logger.create()
@@ -49,7 +49,7 @@ gdrive_watch_callback_token = 'target=calibreweb-watch_files' # nosec
@gdrive.route("/authenticate")
-@login_required
+@user_login_required
@admin_required
def authenticate_google_drive():
try:
@@ -76,7 +76,7 @@ def google_drive_callback():
@gdrive.route("/watch/subscribe")
-@login_required
+@user_login_required
@admin_required
def watch_gdrive():
if not config.config_google_drive_watch_changes_response:
@@ -102,7 +102,7 @@ def watch_gdrive():
@gdrive.route("/watch/revoke")
-@login_required
+@user_login_required
@admin_required
def revoke_watch_gdrive():
last_watch_response = config.config_google_drive_watch_changes_response
diff --git a/cps/helper.py b/cps/helper.py
index b7d4452fa..2d39895ec 100644
--- a/cps/helper.py
+++ b/cps/helper.py
@@ -34,7 +34,7 @@ from flask import send_from_directory, make_response, abort, url_for, Response
from flask_babel import gettext as _
from flask_babel import lazy_gettext as N_
from flask_babel import get_locale
-from flask_login import current_user
+from .cw_login import current_user
from sqlalchemy.sql.expression import true, false, and_, or_, text, func
from sqlalchemy.exc import InvalidRequestError, OperationalError
from werkzeug.datastructures import Headers
diff --git a/cps/jinjia.py b/cps/jinjia.py
index 584e7d6b1..f0b3489dd 100644
--- a/cps/jinjia.py
+++ b/cps/jinjia.py
@@ -27,10 +27,9 @@ import datetime
import mimetypes
from uuid import uuid4
-# from babel.dates import format_date
from flask import Blueprint, request, url_for
from flask_babel import format_date
-from flask_login import current_user
+from .cw_login import current_user
from . import constants, logger
diff --git a/cps/kobo.py b/cps/kobo.py
index 3c7475197..3e01f4103 100644
--- a/cps/kobo.py
+++ b/cps/kobo.py
@@ -36,7 +36,7 @@ from flask import (
redirect,
abort
)
-from flask_login import current_user
+from .cw_login import current_user
from werkzeug.datastructures import Headers
from sqlalchemy import func
from sqlalchemy.sql.expression import and_, or_
@@ -44,7 +44,6 @@ from sqlalchemy.exc import StatementError
from sqlalchemy.sql import select
import requests
-
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
from . import isoLanguages
from .epub import get_epub_layout
diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py
index f49b3fb0b..f99bf77c4 100644
--- a/cps/kobo_auth.py
+++ b/cps/kobo_auth.py
@@ -65,12 +65,14 @@ from os import urandom
from functools import wraps
from flask import g, Blueprint, abort, request
-from flask_login import login_user, current_user, login_required
+from .cw_login import login_user, current_user
from flask_babel import gettext as _
from flask_limiter import RateLimitExceeded
from . import logger, config, calibre_db, db, helper, ub, lm, limiter
from .render_template import render_title_template
+from .usermanagement import user_login_required
+
log = logger.create()
@@ -78,7 +80,7 @@ kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
@kobo_auth.route("/generate_auth_token/")
-@login_required
+@user_login_required
def generate_auth_token(user_id):
warning = False
host_list = request.host.rsplit(':')
@@ -120,7 +122,7 @@ def generate_auth_token(user_id):
@kobo_auth.route("/deleteauthtoken/", methods=["POST"])
-@login_required
+@user_login_required
def delete_auth_token(user_id):
# Invalidate any previously generated Kobo Auth token for this user
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
diff --git a/cps/kobo_sync_status.py b/cps/kobo_sync_status.py
index bff4a705a..ef732aaad 100644
--- a/cps/kobo_sync_status.py
+++ b/cps/kobo_sync_status.py
@@ -17,11 +17,11 @@
# along with this program. If not, see .
-from flask_login import current_user
+from .cw_login import current_user
from . import ub
import datetime
from sqlalchemy.sql.expression import or_, and_, true
-from sqlalchemy import exc
+# from sqlalchemy import exc
# Add the current book id to kobo_synced_books table for current user, if entry is already present,
diff --git a/cps/oauth_bb.py b/cps/oauth_bb.py
index 76b8d2ba8..0a897fc71 100644
--- a/cps/oauth_bb.py
+++ b/cps/oauth_bb.py
@@ -30,7 +30,7 @@ from flask_dance.consumer import oauth_authorized, oauth_error
from flask_dance.contrib.github import make_github_blueprint, github
from flask_dance.contrib.google import make_google_blueprint, google
from oauthlib.oauth2 import TokenExpiredError, InvalidGrantError
-from flask_login import login_user, current_user, login_required
+from .cw_login import login_user, current_user, login_required
from sqlalchemy.orm.exc import NoResultFound
from . import constants, logger, config, app, ub
@@ -340,7 +340,7 @@ def github_login():
@oauth.route('/unlink/github', methods=["GET"])
-@login_required
+@user_login_required
def github_login_unlink():
return unlink_oauth(oauthblueprints[0]['id'])
@@ -364,6 +364,6 @@ def google_login():
@oauth.route('/unlink/google', methods=["GET"])
-@login_required
+@user_login_required
def google_login_unlink():
return unlink_oauth(oauthblueprints[1]['id'])
diff --git a/cps/opds.py b/cps/opds.py
index af9e4d9aa..34c12885e 100644
--- a/cps/opds.py
+++ b/cps/opds.py
@@ -25,73 +25,25 @@ import json
from urllib.parse import unquote_plus
from flask import Blueprint, request, render_template, make_response, abort, Response, g
-from functools import wraps
-# from flask_login import current_user
from flask_babel import get_locale
from flask_babel import gettext as _
-from flask_httpauth import HTTPBasicAuth
-from werkzeug.datastructures import Authorization
-from werkzeug.security import generate_password_hash, check_password_hash
+
from sqlalchemy.sql.expression import func, text, or_, and_, true
from sqlalchemy.exc import InvalidRequestError, OperationalError
from . import logger, config, db, calibre_db, ub, isoLanguages, constants
-# from .usermanagement import requires_basic_auth_if_no_ano
+from .usermanagement import requires_basic_auth_if_no_ano, auth
from .helper import get_download_link, get_book_cover
from .pagination import Pagination
from .web import render_read_books
-from . import limiter, services
+
opds = Blueprint('opds', __name__)
-auth = HTTPBasicAuth()
log = logger.create()
-@auth.verify_password
-def verify_password(username, password):
- user = ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).first()
- if config.config_anonbrowse == 1 and user.name.lower() == "guest":
- return user
- if bool(user and check_password_hash(str(user.password), password)) and user.name != "Guest":
- [limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
- return user
- else:
- ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
- log.warning('OPDS Login failed for user "%s" IP-address: %s', username, ip_address)
- return None
-
-
-def requires_basic_auth_if_no_ano(f):
- @wraps(f)
- def decorated(*args, **kwargs):
- authorisation = auth.get_auth()
- if config.config_anonbrowse == 1 and not authorisation:
- authorisation = Authorization(
- b"Basic", {'username': "Guest", 'password': ""})
- status = None
- user = auth.authenticate(authorisation, "")
- if config.config_login_type == constants.LOGIN_LDAP and services.ldap:
- login_result, error = services.ldap.bind_user(authorisation.username, authorisation.password)
- if login_result:
- [limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
- elif login_result is not None:
- log.error(error)
- user = None
- if user in (False, None):
- status = 401
- if status:
- try:
- return auth.auth_error_callback(status)
- except TypeError:
- return auth.auth_error_callback()
- g.flask_httpauth_user = user if user is not True \
- else auth.username if auth else None
- return f(*args, **kwargs)
- return decorated
-
-
@opds.route("/opds/")
@opds.route("/opds")
@requires_basic_auth_if_no_ano
diff --git a/cps/remotelogin.py b/cps/remotelogin.py
index ee4346702..73b712e22 100644
--- a/cps/remotelogin.py
+++ b/cps/remotelogin.py
@@ -25,12 +25,13 @@ from datetime import datetime
from functools import wraps
from flask import Blueprint, request, make_response, abort, url_for, flash, redirect
-from flask_login import login_required, current_user, login_user
+from .cw_login import login_user, current_user
from flask_babel import gettext as _
from sqlalchemy.sql.expression import true
from . import config, logger, ub
from .render_template import render_title_template
+from .usermanagement import user_login_required
remotelogin = Blueprint('remotelogin', __name__)
@@ -65,7 +66,7 @@ def remote_login():
@remotelogin.route('/verify/')
@remote_login_required
-@login_required
+@user_login_required
def verify_token(token):
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()
diff --git a/cps/render_template.py b/cps/render_template.py
index 89e067d0b..bf0356d0d 100644
--- a/cps/render_template.py
+++ b/cps/render_template.py
@@ -19,14 +19,13 @@
from flask import render_template, g, abort, request
from flask_babel import gettext as _
from werkzeug.local import LocalProxy
-from flask_login import current_user
+from .cw_login import current_user
from sqlalchemy.sql.expression import or_
from . import config, constants, logger, ub
from .ub import User
-
log = logger.create()
def get_sidebar_config(kwargs=None):
diff --git a/cps/search.py b/cps/search.py
index f214b3a8b..4ae8a5d72 100644
--- a/cps/search.py
+++ b/cps/search.py
@@ -19,7 +19,7 @@ from datetime import datetime
from flask import Blueprint, request, redirect, url_for, flash
from flask import session as flask_session
-from flask_login import current_user
+from .cw_login import current_user
from flask_babel import format_date
from flask_babel import gettext as _
from sqlalchemy.sql.expression import func, not_, and_, or_, text, true
@@ -30,6 +30,7 @@ from .usermanagement import login_required_if_no_ano
from .render_template import render_title_template
from .pagination import Pagination
+
search = Blueprint('search', __name__)
log = logger.create()
diff --git a/cps/search_metadata.py b/cps/search_metadata.py
index e018da326..b818a87c0 100644
--- a/cps/search_metadata.py
+++ b/cps/search_metadata.py
@@ -24,14 +24,14 @@ import os
import sys
from flask import Blueprint, Response, request, url_for
-from flask_login import current_user
-from flask_login import login_required
+from .cw_login import current_user
from flask_babel import get_locale
from sqlalchemy.exc import InvalidRequestError, OperationalError
from sqlalchemy.orm.attributes import flag_modified
from cps.services.Metadata import Metadata
from . import constants, logger, ub, web_server
+from .usermanagement import user_login_required
# current_milli_time = lambda: int(round(time() * 1000))
@@ -81,7 +81,7 @@ cl = list_classes(new_list)
@meta.route("/metadata/provider")
-@login_required
+@user_login_required
def metadata_provider():
active = current_user.view_settings.get("metadata", {})
provider = list()
@@ -95,7 +95,7 @@ def metadata_provider():
@meta.route("/metadata/provider", methods=["POST"])
@meta.route("/metadata/provider/", methods=["POST"])
-@login_required
+@user_login_required
def metadata_change_active_provider(prov_name):
new_state = request.get_json()
active = current_user.view_settings.get("metadata", {})
@@ -122,7 +122,7 @@ def metadata_change_active_provider(prov_name):
@meta.route("/metadata/search", methods=["POST"])
-@login_required
+@user_login_required
def metadata_search():
query = request.form.to_dict().get("query")
data = list()
diff --git a/cps/shelf.py b/cps/shelf.py
index 9d9693222..c8f434467 100644
--- a/cps/shelf.py
+++ b/cps/shelf.py
@@ -25,13 +25,13 @@ from datetime import datetime
from flask import Blueprint, flash, redirect, request, url_for, abort
from flask_babel import gettext as _
-from flask_login import current_user, login_required
+from .cw_login import current_user
from sqlalchemy.exc import InvalidRequestError, OperationalError
from sqlalchemy.sql.expression import func, true
from . import calibre_db, config, db, logger, ub
from .render_template import render_title_template
-from .usermanagement import login_required_if_no_ano
+from .usermanagement import login_required_if_no_ano, user_login_required
log = logger.create()
@@ -39,7 +39,7 @@ shelf = Blueprint('shelf', __name__)
@shelf.route("/shelf/add//", methods=["POST"])
-@login_required
+@user_login_required
def add_to_shelf(shelf_id, book_id):
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
@@ -103,7 +103,7 @@ def add_to_shelf(shelf_id, book_id):
@shelf.route("/shelf/massadd/", methods=["POST"])
-@login_required
+@user_login_required
def search_to_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf is None:
@@ -155,7 +155,7 @@ def search_to_shelf(shelf_id):
@shelf.route("/shelf/remove//", methods=["POST"])
-@login_required
+@user_login_required
def remove_from_shelf(shelf_id, book_id):
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
@@ -212,14 +212,14 @@ def remove_from_shelf(shelf_id, book_id):
@shelf.route("/shelf/create", methods=["GET", "POST"])
-@login_required
+@user_login_required
def create_shelf():
shelf = ub.Shelf()
return create_edit_shelf(shelf, page_title=_("Create a Shelf"), page="shelfcreate")
@shelf.route("/shelf/edit/", methods=["GET", "POST"])
-@login_required
+@user_login_required
def edit_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if not check_shelf_edit_permissions(shelf):
@@ -229,7 +229,7 @@ def edit_shelf(shelf_id):
@shelf.route("/shelf/delete/", methods=["POST"])
-@login_required
+@user_login_required
def delete_shelf(shelf_id):
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
try:
@@ -259,7 +259,7 @@ def show_shelf(shelf_id, sort_param, page):
@shelf.route("/shelf/order/", methods=["GET", "POST"])
-@login_required
+@user_login_required
def order_shelf(shelf_id):
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
if shelf and check_shelf_view_permissions(shelf):
diff --git a/cps/tasks_status.py b/cps/tasks_status.py
index fc3c99149..49feb67c2 100644
--- a/cps/tasks_status.py
+++ b/cps/tasks_status.py
@@ -17,7 +17,7 @@
from markupsafe import escape
from flask import Blueprint, jsonify
-from flask_login import login_required, current_user
+from .cw_login import current_user
from flask_babel import gettext as _
from flask_babel import format_datetime
from babel.units import format_unit
@@ -26,6 +26,7 @@ from . import logger
from .render_template import render_title_template
from .services.worker import WorkerThread, STAT_WAITING, STAT_FAIL, STAT_STARTED, STAT_FINISH_SUCCESS, STAT_ENDED, \
STAT_CANCELLED
+from .usermanagement import user_login_required
tasks = Blueprint('tasks', __name__)
@@ -33,14 +34,14 @@ log = logger.create()
@tasks.route("/ajax/emailstat")
-@login_required
+@user_login_required
def get_email_status_json():
tasks = WorkerThread.get_instance().tasks
return jsonify(render_task_status(tasks))
@tasks.route("/tasks")
-@login_required
+@user_login_required
def get_tasks_status():
# if current user admin, show all email, otherwise only own emails
return render_title_template('tasks.html', title=_("Tasks"), page="tasks")
diff --git a/cps/ub.py b/cps/ub.py
index 85819859e..556ea8a9f 100644
--- a/cps/ub.py
+++ b/cps/ub.py
@@ -26,8 +26,8 @@ import uuid
from flask import session as flask_session
from binascii import hexlify
-from flask_login import AnonymousUserMixin, current_user
-from flask_login import user_logged_in
+from .cw_login import AnonymousUserMixin, current_user
+from .cw_login import user_logged_in
try:
from flask_dance.consumer.backend.sqla import OAuthConsumerMixin
@@ -71,17 +71,22 @@ def signal_store_user_session(object, user):
def store_user_session():
- if flask_session.get('user_id', ""):
- flask_session['_user_id'] = flask_session.get('user_id', "")
+ #if flask_session.get('user_id', ""):
+ # flask_session['_user_id'] = flask_session.get('user_id', "")
+ _user = flask_session.get('_user_id', "")
+ _id = flask_session.get('_id', "")
+ _random = flask_session.get('_random', "")
+
if flask_session.get('_user_id', ""):
try:
- if not check_user_session(flask_session.get('_user_id', ""), flask_session.get('_id', "")):
- user_session = User_Sessions(flask_session.get('_user_id', ""), flask_session.get('_id', ""))
+ if not check_user_session(_user, _id):
+ expiry = int((datetime.datetime.now() + datetime.timedelta(days=31)).timestamp())
+ user_session = User_Sessions(_user, _id, _random, expiry)
session.add(user_session)
session.commit()
- log.debug("Login and store session : " + flask_session.get('_id', ""))
+ log.debug("Login and store session : " + _id)
else:
- log.debug("Found stored session: " + flask_session.get('_id', ""))
+ log.debug("Found stored session: " + _id)
except (exc.OperationalError, exc.InvalidRequestError) as e:
session.rollback()
log.exception(e)
@@ -335,11 +340,16 @@ class User_Sessions(Base):
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('user.id'))
session_key = Column(String, default="")
+ random = Column(String, default="")
+ expiry = Column(String, default="")
- def __init__(self, user_id, session_key):
+
+ def __init__(self, user_id, session_key, random, expiry):
super().__init__()
self.user_id = user_id
self.session_key = session_key
+ self.random = random
+ self.expiry = expiry
# Baseclass representing Shelfs in calibre-web in app.db
diff --git a/cps/usermanagement.py b/cps/usermanagement.py
index 93008eef0..e9cf04751 100644
--- a/cps/usermanagement.py
+++ b/cps/usermanagement.py
@@ -19,16 +19,121 @@
from functools import wraps
from sqlalchemy.sql.expression import func
-from flask_login import login_required, login_user
+from .cw_login import login_required
+from flask import request, g
+from flask_httpauth import HTTPBasicAuth
+from werkzeug.datastructures import Authorization
+from werkzeug.security import check_password_hash
+
+from . import lm, ub, config, logger, limiter, constants, services
-from . import lm, ub, config, logger, limiter
log = logger.create()
+
+'''class HTTPProxyAuth(HTTPAuth):
+ def __init__(self, scheme='Proxy', realm=None, header=None):
+ super(HTTPProxyAuth, self).__init__(scheme, realm, header)
+ self.user = None
+ self.verify_user_callback = None
+
+ def set_user(self, username):
+ self.user = username if username else None
+
+ def verify_login(self, f):
+ self.verify_user_callback = f
+ return f
+
+ def login_required(self, f=None, role=None, optional=None):
+ if f is not None and \
+ (role is not None or optional is not None): # pragma: no cover
+ raise ValueError(
+ 'role and optional are the only supported arguments')
+
+ def login_required_internal(f):
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ if self.user:
+ g.flask_httpauth_user = self.user
+ return self.ensure_sync(f)(*args, **kwargs)
+ return decorated
+
+ if f:
+ return login_required_internal(f)
+ return login_required_internal
+
+
+
+ def authenticate(self, _auth, stored_password=None):
+ req = getattr(_auth, 'req', '')
+ if self.verify_user_callback:
+ return self.ensure_sync(self.verify_user_callback)(req)'''
+
+
+auth = HTTPBasicAuth()
+# proxy_auth = HTTPProxyAuth()
+
+
+@auth.verify_password
+def verify_password(username, password):
+ user = ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).first()
+ if user:
+ if user.name.lower() == "guest":
+ if config.config_anonbrowse == 1:
+ return user
+ if config.config_login_type == constants.LOGIN_LDAP and services.ldap:
+ login_result, error = services.ldap.bind_user(user.name, password)
+ if login_result:
+ [limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
+ return user
+ if error is not None:
+ log.error(error)
+ elif check_password_hash(str(user.password), password):
+ [limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
+ return user
+ ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
+ log.warning('OPDS Login failed for user "%s" IP-address: %s', username, ip_address)
+ return None
+
+
+def requires_basic_auth_if_no_ano(f):
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ authorisation = auth.get_auth()
+ status = None
+ user = None
+ if config.config_allow_reverse_proxy_header_login and not authorisation:
+ user = load_user_from_reverse_proxy_header(request)
+ if config.config_anonbrowse == 1 and not authorisation:
+ authorisation = Authorization(
+ b"Basic", {'username': "Guest", 'password': ""})
+ if not user:
+ user = auth.authenticate(authorisation, "")
+ if user in (False, None):
+ status = 401
+ if status:
+ try:
+ return auth.auth_error_callback(status)
+ except TypeError:
+ return auth.auth_error_callback()
+ g.flask_httpauth_user = user if user is not True \
+ else auth.username if auth else None
+ return auth.ensure_sync(f)(*args, **kwargs)
+ return decorated
+
+
def login_required_if_no_ano(func):
@wraps(func)
def decorated_view(*args, **kwargs):
+ if config.config_allow_reverse_proxy_header_login:
+ user = load_user_from_reverse_proxy_header(request)
+ if user:
+ g.flask_httpauth_user = user
+ return func(*args, **kwargs)
+ # proxy_auth.set_user(user)
+ # return proxy_auth.login_required(func)(*args, **kwargs)
+ g.flask_httpauth_user = None
if config.config_anonbrowse == 1:
return func(*args, **kwargs)
return login_required(func)(*args, **kwargs)
@@ -36,47 +141,39 @@ def login_required_if_no_ano(func):
return decorated_view
-'''def _load_user_from_auth_header(username, password):
- limiter.check()
- user = _fetch_user_by_name(username)
- if bool(user and check_password_hash(str(user.password), password)) and user.name != "Guest":
- [limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
- login_user(user)
- return user
- else:
- ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
- log.warning('OPDS Login failed for user "%s" IP-address: %s', username, ip_address)
- return None
+def user_login_required(func):
+ @wraps(func)
+ def decorated_view(*args, **kwargs):
+ if config.config_allow_reverse_proxy_header_login:
+ user = load_user_from_reverse_proxy_header(request)
+ if user:
+ g.flask_httpauth_user = user
+ return func(*args, **kwargs)
+ g.flask_httpauth_user = None
+ return login_required(func)(*args, **kwargs)
+
+ return decorated_view
-def _authenticate():
- return Response(
- 'Could not verify your access level for that URL.\n'
- 'You have to login with proper credentials', 401,
- {'WWW-Authenticate': 'Basic realm="Login Required"'})'''
-
-
-def _fetch_user_by_name(username):
- return ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).first()
+def load_user_from_reverse_proxy_header(req):
+ rp_header_name = config.config_reverse_proxy_login_header_name
+ if rp_header_name:
+ rp_header_username = req.headers.get(rp_header_name)
+ if rp_header_username:
+ user = ub.session.query(ub.User).filter(func.lower(ub.User.name) == rp_header_username.lower()).first()
+ if user:
+ [limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
+ return user
+ return None
@lm.user_loader
-def load_user(user_id):
+def load_user(user_id, random, session_key):
user = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first()
- return user
-
-
-@lm.request_loader
-def load_user_from_reverse_proxy_header(req):
- if config.config_allow_reverse_proxy_header_login:
- rp_header_name = config.config_reverse_proxy_login_header_name
- if rp_header_name:
- rp_header_username = req.headers.get(rp_header_name)
- if rp_header_username:
- user = _fetch_user_by_name(rp_header_username)
- if user:
- [limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
- login_user(user)
- return user
- return None
+ entry = ub.session.query(ub.User_Sessions).filter(ub.User_Sessions.random == random,
+ ub.User_Sessions.session_key == session_key).first()
+ if entry and entry.id == user.id:
+ return user
+ else:
+ return None
diff --git a/cps/web.py b/cps/web.py
index b53fe4214..feea0cc68 100644
--- a/cps/web.py
+++ b/cps/web.py
@@ -29,7 +29,7 @@ from flask import request, redirect, send_from_directory, make_response, flash,
from flask import session as flask_session
from flask_babel import gettext as _
from flask_babel import get_locale
-from flask_login import login_user, logout_user, login_required, current_user
+from .cw_login import login_user, logout_user, current_user
from flask_limiter import RateLimitExceeded
from flask_limiter.util import get_remote_address
from sqlalchemy.exc import IntegrityError, InvalidRequestError, OperationalError
@@ -59,6 +59,7 @@ from .kobo_sync_status import change_archived_books
from . import limiter
from .services.worker import WorkerThread
from .tasks_status import render_task_status
+from .usermanagement import user_login_required
feature_support = {
@@ -143,14 +144,14 @@ def viewer_required(f):
@web.route("/ajax/emailstat")
-@login_required
+@user_login_required
def get_email_status_json():
tasks = WorkerThread.get_instance().tasks
return jsonify(render_task_status(tasks))
@web.route("/ajax/bookmark//", methods=['POST'])
-@login_required
+@user_login_required
def set_bookmark(book_id, book_format):
bookmark_key = request.form["bookmark"]
ub.session.query(ub.Bookmark).filter(and_(ub.Bookmark.user_id == int(current_user.id),
@@ -170,7 +171,7 @@ def set_bookmark(book_id, book_format):
@web.route("/ajax/toggleread/", methods=['POST'])
-@login_required
+@user_login_required
def toggle_read(book_id):
message = edit_book_read_status(book_id)
if message:
@@ -180,7 +181,7 @@ def toggle_read(book_id):
@web.route("/ajax/togglearchived/", methods=['POST'])
-@login_required
+@user_login_required
def toggle_archived(book_id):
is_archived = change_archived_books(book_id, message="Book {} archive bit toggled".format(book_id))
if is_archived:
@@ -204,7 +205,7 @@ def update_view():
'''
@web.route("/ajax/getcomic///")
-@login_required
+@user_login_required
def get_comic_book(book_id, book_format, page):
book = calibre_db.get_book(book_id)
if not book:
@@ -816,7 +817,7 @@ def books_list(data, sort_param, book_id, page):
@web.route("/table")
-@login_required
+@user_login_required
def books_table():
visibility = current_user.view_settings.get('table', {})
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
@@ -825,7 +826,7 @@ def books_table():
@web.route("/ajax/listbooks")
-@login_required
+@user_login_required
def list_books():
off = int(request.args.get("offset") or 0)
limit = int(request.args.get("limit") or config.config_books_per_page)
@@ -906,7 +907,7 @@ def list_books():
@web.route("/ajax/table_settings", methods=['POST'])
-@login_required
+@user_login_required
def update_table_settings():
current_user.view_settings['table'] = json.loads(request.data)
try:
@@ -1443,7 +1444,7 @@ def login_post():
@web.route('/logout')
-@login_required
+@user_login_required
def logout():
if current_user is not None and current_user.is_authenticated:
ub.delete_user_session(current_user.id, flask_session.get('_id', ""))
@@ -1528,7 +1529,7 @@ def change_profile(kobo_support, local_oauth_check, oauth_status, translations,
@web.route("/me", methods=["GET", "POST"])
-@login_required
+@user_login_required
def profile():
languages = calibre_db.speaking_language()
translations = get_available_locale()
diff --git a/optional-requirements.txt b/optional-requirements.txt
index 77f41e1e2..3f6b6e37d 100644
--- a/optional-requirements.txt
+++ b/optional-requirements.txt
@@ -42,7 +42,7 @@ natsort>=2.2.0,<8.5.0
comicapi>=2.2.0,<3.3.0
# Kobo integration
-jsonschema>=3.2.0,<4.23.0
+jsonschema>=3.2.0,<4.24.0
# Hide console Window on Windows
pywin32>=220,<310 ; sys_platform == 'win32'
diff --git a/requirements.txt b/requirements.txt
index 75940054c..fd6a808c9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,7 +2,7 @@ Werkzeug<3.0.0
APScheduler>=3.6.3,<3.11.0
Babel>=1.3,<3.0
Flask-Babel>=0.11.1,<4.1.0
-Flask-Login>=0.3.2,<0.6.4
+# Flask-Login>=0.3.2,<0.6.4
Flask-Principal>=0.3.2,<0.5.1
Flask>=1.0.2,<3.1.0
iso-639>=0.4.5,<0.5.0