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