From 623f5c8ef0699081a32493ebf05f7dcd8f36c673 Mon Sep 17 00:00:00 2001 From: Jonathan Rehm Date: Fri, 7 Jul 2017 18:18:03 -0700 Subject: [PATCH] Add "magic link" functionality When using a device that is bothersome to log in on (e.g. a Kindle) you can use a magic link to log in via another device. Configuration was added and is disabled by default. --- cps/redirect.py | 25 +++++++ cps/templates/admin.html | 2 + cps/templates/config_edit.html | 4 ++ cps/templates/login.html | 4 ++ cps/templates/remote_login.html | 40 ++++++++++++ cps/ub.py | 33 ++++++++++ cps/web.py | 111 +++++++++++++++++++++++++++++++- readme.md | 4 ++ 8 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 cps/redirect.py create mode 100644 cps/templates/remote_login.html diff --git a/cps/redirect.py b/cps/redirect.py new file mode 100644 index 00000000..fa3fa5c7 --- /dev/null +++ b/cps/redirect.py @@ -0,0 +1,25 @@ +# http://flask.pocoo.org/snippets/62/ + +from urlparse import urlparse, urljoin +from flask import request, url_for, redirect + + +def is_safe_url(target): + ref_url = urlparse(request.host_url) + test_url = urlparse(urljoin(request.host_url, target)) + return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc + + +def get_redirect_target(): + for target in request.values.get('next'), request.referrer: + if not target: + continue + if is_safe_url(target): + return target + + +def redirect_back(endpoint, **values): + target = request.form['next'] + if not target or not is_safe_url(target): + target = url_for(endpoint, **values) + return redirect(target) diff --git a/cps/templates/admin.html b/cps/templates/admin.html index 18d99ab8..96d2ceb8 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -64,6 +64,7 @@ {{_('Uploading')}} {{_('Public registration')}} {{_('Anonymous browsing')}} + {{_('Remote Login')}} {{config.config_calibre_dir}} @@ -73,6 +74,7 @@ {% if config.config_uploading %}{% else %}{% endif %} {% if config.config_public_reg %}{% else %}{% endif %} {% if config.config_anonbrowse %}{% else %}{% endif %} + {% if config.config_remote_login %}{% else %}{% endif %}
{{_('Configuration')}}

{{_('Administration')}}

diff --git a/cps/templates/config_edit.html b/cps/templates/config_edit.html index 733bd608..52b5d09c 100644 --- a/cps/templates/config_edit.html +++ b/cps/templates/config_edit.html @@ -93,6 +93,10 @@ +
+ + +

{{_('Default Settings for new users')}}

diff --git a/cps/templates/login.html b/cps/templates/login.html index 435b95f6..3e8ebe1e 100644 --- a/cps/templates/login.html +++ b/cps/templates/login.html @@ -3,6 +3,7 @@

{{_('Login')}}

+
@@ -17,6 +18,9 @@
+ {% if remote_login %} + {{_('Log in with magic link')}} + {% endif %}
{% if error %} diff --git a/cps/templates/remote_login.html b/cps/templates/remote_login.html new file mode 100644 index 00000000..92c89c78 --- /dev/null +++ b/cps/templates/remote_login.html @@ -0,0 +1,40 @@ +{% extends "layout.html" %} +{% block body %} +
+

{{_('Remote Login')}}

+

+ {{_('Using your another device, visit')}} {{verify_url}} {{_('and log in')}}. +

+

+ {{_('Once you do so, you will automatically get logged in on this device.')}} +

+

+ {{_('The link will expire after %s minutes.' % 10)}} +

+
+{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/cps/ub.py b/cps/ub.py index 373053db..dc377cba 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -11,6 +11,8 @@ import logging from werkzeug.security import generate_password_hash from flask_babel import gettext as _ import json +import datetime +from binascii import hexlify dbpath = os.path.join(os.path.normpath(os.getenv("CALIBRE_DBPATH", os.path.dirname(os.path.realpath(__file__)) + os.sep + ".." + os.sep)), "app.db") engine = create_engine('sqlite:///{0}'.format(dbpath), echo=False) @@ -260,11 +262,29 @@ class Settings(Base): config_google_drive_calibre_url_base = Column(String) config_google_drive_watch_changes_response = Column(String) config_columns_to_ignore = Column(String) + config_remote_login = Column(Boolean) def __repr__(self): pass +class RemoteAuthToken(Base): + __tablename__ = 'remote_auth_token' + + id = Column(Integer, primary_key=True) + auth_token = Column(String(8), unique=True) + user_id = Column(Integer, ForeignKey('user.id')) + verified = Column(Boolean, default=False) + expiration = Column(DateTime) + + def __init__(self): + self.auth_token = hexlify(os.urandom(4)) + self.expiration = datetime.datetime.now() + datetime.timedelta(minutes=10) # 10 min from now + + def __repr__(self): + return '' % self.id + + # Class holds all application specific settings in calibre-web class Config: def __init__(self): @@ -299,6 +319,7 @@ class Config: self.config_columns_to_ignore = data.config_columns_to_ignore self.db_configured = bool(self.config_calibre_dir is not None and (not self.config_use_google_drive or os.path.exists(self.config_calibre_dir + '/metadata.db'))) + self.config_remote_login = data.config_remote_login @property def get_main_dir(self): @@ -449,6 +470,16 @@ def migrate_Database(): session.commit() if session.query(User).filter(User.role.op('&')(ROLE_ANONYMOUS) == ROLE_ANONYMOUS).first() is None: create_anonymous_user() + try: + session.query(exists().where(Settings.config_remote_login)).scalar() + except exc.OperationalError: + conn = engine.connect() + conn.execute("ALTER TABLE Settings ADD column `config_remote_login` INTEGER DEFAULT 0") + +def clean_database(): + # Remove expired remote login tokens + now = datetime.datetime.now() + session.query(RemoteAuthToken).filter(now > RemoteAuthToken.expiration).delete() def create_default_config(): settings = Settings() @@ -529,7 +560,9 @@ if not os.path.exists(dbpath): except Exception: raise else: + Base.metadata.create_all(engine) migrate_Database() + clean_database() # Generate global Settings Object accecable from every file config = Config() diff --git a/cps/web.py b/cps/web.py index c810ee7f..456ed0fb 100755 --- a/cps/web.py +++ b/cps/web.py @@ -58,6 +58,7 @@ import shutil import gdriveutils import tempfile import hashlib +from redirect import redirect_back, is_safe_url from tornado import version as tornadoVersion @@ -372,6 +373,21 @@ def login_required_if_no_ano(func): return login_required(func) +def remote_login_required(f): + @wraps(f) + def inner(*args, **kwargs): + if config.config_remote_login: + return f(*args, **kwargs) + if request.is_xhr: + data = {'status': 'error', 'message': 'Forbidden'} + response = make_response(json.dumps(data, ensure_ascii=false)) + response.headers["Content-Type"] = "application/json; charset=utf-8" + return response, 403 + abort(403) + + return inner + + # custom jinja filters @app.template_filter('shortentitle') def shortentitle_filter(s): @@ -1805,14 +1821,20 @@ def login(): if user and check_password_hash(user.password, form['password']): login_user(user, remember=True) + flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") - return redirect(url_for("index")) + return redirect_back(url_for("index")) else: ipAdress=request.headers.get('X-Forwarded-For', request.remote_addr) app.logger.info('Login failed for user "' + form['username'] + '" IP-adress: ' + ipAdress) flash(_(u"Wrong Username or Password"), category="error") - return render_title_template('login.html', title=_(u"login")) + next_url = request.args.get('next') + if next_url is None or not is_safe_url(next_url): + next_url = url_for('index') + + return render_title_template('login.html', title=_(u"login"), next_url=next_url, + remote_login=config.config_remote_login) @app.route('/logout') @@ -1823,6 +1845,87 @@ def logout(): return redirect(url_for('login')) +@app.route('/remote/login') +@remote_login_required +def remote_login(): + auth_token = ub.RemoteAuthToken() + ub.session.add(auth_token) + ub.session.commit() + + verify_url = url_for('verify_token', token=auth_token.auth_token, _external=true) + + return render_title_template('remote_login.html', title=_(u"login"), token=auth_token.auth_token, + verify_url=verify_url) + + +@app.route('/verify/') +@remote_login_required +@login_required +def verify_token(token): + auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first() + + # Token not found + if auth_token is None: + flash(_(u"Token not found"), category="error") + return redirect(url_for('index')) + + # Token expired + if datetime.datetime.now() > auth_token.expiration: + ub.session.delete(auth_token) + ub.session.commit() + + flash(_(u"Token has expired"), category="error") + return redirect(url_for('index')) + + # Update token with user information + auth_token.user_id = current_user.id + auth_token.verified = True + ub.session.commit() + + flash(_(u"Success! Please return to your device"), category="success") + return redirect(url_for('index')) + + +@app.route('/ajax/verify_token', methods=['POST']) +@remote_login_required +def token_verified(): + token = request.form['token'] + auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first() + + data = {} + + # Token not found + if auth_token is None: + data['status'] = 'error' + data['message'] = _(u"Token not found") + + # Token expired + elif datetime.datetime.now() > auth_token.expiration: + ub.session.delete(auth_token) + ub.session.commit() + + data['status'] = 'error' + data['message'] = _(u"Token has expired") + + elif not auth_token.verified: + data['status'] = 'not_verified' + + else: + user = ub.session.query(ub.User).filter(ub.User.id == auth_token.user_id).first() + login_user(user) + + ub.session.delete(auth_token) + ub.session.commit() + + data['status'] = 'success' + flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") + + response = make_response(json.dumps(data, ensure_ascii=false)) + response.headers["Content-Type"] = "application/json; charset=utf-8" + + return response + + @app.route('/send/') @login_required @download_required @@ -2178,6 +2281,10 @@ def configuration_helper(origin): content.config_anonbrowse = 1 if "config_public_reg" in to_save and to_save["config_public_reg"] == "on": content.config_public_reg = 1 + content.config_remote_login = ("config_remote_login" in to_save and to_save["config_remote_login"] == "on") + + if not content.config_remote_login: + ub.session.query(ub.RemoteAuthToken).delete() content.config_default_role = 0 if "admin_role" in to_save: diff --git a/readme.md b/readme.md index b6567365..038b1da5 100755 --- a/readme.md +++ b/readme.md @@ -26,6 +26,7 @@ Calibre Web is a web app providing a clean interface for browsing, reading and d - Support for Calibre custom columns - Fine grained per-user permissions - Self update capability +- "Magic Link" login to make it easy to log on eReaders ## Quick start @@ -55,6 +56,9 @@ Tick to allow not logged in users to browse the catalog, anonymous user permissi Enable uploading: Tick to enable uploading of PDF, epub, FB2. This requires the imagemagick library to be installed. +Enable remote login ("magic link"): +Tick to enable remote login, i.e. a link that allows user to log in via a different device. + ## Requirements Python 2.7+