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.
This commit is contained in:
Jonathan Rehm 2017-07-07 18:18:03 -07:00
parent 5276bda153
commit 623f5c8ef0
8 changed files with 221 additions and 2 deletions

25
cps/redirect.py Normal file
View File

@ -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)

View File

@ -64,6 +64,7 @@
<th>{{_('Uploading')}}</th> <th>{{_('Uploading')}}</th>
<th>{{_('Public registration')}}</th> <th>{{_('Public registration')}}</th>
<th>{{_('Anonymous browsing')}}</th> <th>{{_('Anonymous browsing')}}</th>
<th>{{_('Remote Login')}}</th>
</tr> </tr>
<tr> <tr>
<td>{{config.config_calibre_dir}}</td> <td>{{config.config_calibre_dir}}</td>
@ -73,6 +74,7 @@
<td>{% if config.config_uploading %}<span class="glyphicon glyphicon-ok"></span>{% else %}<span class="glyphicon glyphicon-remove"></span>{% endif %}</td> <td>{% if config.config_uploading %}<span class="glyphicon glyphicon-ok"></span>{% else %}<span class="glyphicon glyphicon-remove"></span>{% endif %}</td>
<td>{% if config.config_public_reg %}<span class="glyphicon glyphicon-ok"></span>{% else %}<span class="glyphicon glyphicon-remove"></span>{% endif %}</td> <td>{% if config.config_public_reg %}<span class="glyphicon glyphicon-ok"></span>{% else %}<span class="glyphicon glyphicon-remove"></span>{% endif %}</td>
<td>{% if config.config_anonbrowse %}<span class="glyphicon glyphicon-ok"></span>{% else %}<span class="glyphicon glyphicon-remove"></span>{% endif %}</td> <td>{% if config.config_anonbrowse %}<span class="glyphicon glyphicon-ok"></span>{% else %}<span class="glyphicon glyphicon-remove"></span>{% endif %}</td>
<td>{% if config.config_remote_login %}<span class="glyphicon glyphicon-ok"></span>{% else %}<span class="glyphicon glyphicon-remove"></span>{% endif %}</td>
</table> </table>
<div class="btn btn-default"><a href="{{url_for('configuration')}}">{{_('Configuration')}}</a></div> <div class="btn btn-default"><a href="{{url_for('configuration')}}">{{_('Configuration')}}</a></div>
<h2>{{_('Administration')}}</h2> <h2>{{_('Administration')}}</h2>

View File

@ -93,6 +93,10 @@
<input type="checkbox" id="config_public_reg" name="config_public_reg" {% if content.config_public_reg %}checked{% endif %}> <input type="checkbox" id="config_public_reg" name="config_public_reg" {% if content.config_public_reg %}checked{% endif %}>
<label for="config_public_reg">{{_('Enable public registration')}}</label> <label for="config_public_reg">{{_('Enable public registration')}}</label>
</div> </div>
<div class="form-group">
<input type="checkbox" id="config_remote_login" name="config_remote_login" {% if content.config_remote_login %}checked{% endif %}>
<label for="config_remote_login">{{_('Enable remote login ("magic link")')}}</label>
</div>
<h2>{{_('Default Settings for new users')}}</h2> <h2>{{_('Default Settings for new users')}}</h2>
<div class="form-group"> <div class="form-group">
<input type="checkbox" name="admin_role" id="admin_role" {% if content.role_admin() %}checked{% endif %}> <input type="checkbox" name="admin_role" id="admin_role" {% if content.role_admin() %}checked{% endif %}>

View File

@ -3,6 +3,7 @@
<div class="well col-sm-6 col-sm-offset-2"> <div class="well col-sm-6 col-sm-offset-2">
<h2 style="margin-top: 0">{{_('Login')}}</h2> <h2 style="margin-top: 0">{{_('Login')}}</h2>
<form method="POST" role="form"> <form method="POST" role="form">
<input type="hidden" name="next" value="{{next_url}}">
<div class="form-group"> <div class="form-group">
<label for="username">{{_('Username')}}</label> <label for="username">{{_('Username')}}</label>
<input type="text" class="form-control" id="username" name="username" placeholder="{{_('Username')}}"> <input type="text" class="form-control" id="username" name="username" placeholder="{{_('Username')}}">
@ -17,6 +18,9 @@
</label> </label>
</div> </div>
<button type="submit" name="submit" class="btn btn-default">{{_('Submit')}}</button> <button type="submit" name="submit" class="btn btn-default">{{_('Submit')}}</button>
{% if remote_login %}
<a href="{{url_for('remote_login')}}" class="pull-right">{{_('Log in with magic link')}}</a>
{% endif %}
</form> </form>
</div> </div>
{% if error %} {% if error %}

View File

@ -0,0 +1,40 @@
{% extends "layout.html" %}
{% block body %}
<div class="well">
<h2 style="margin-top: 0">{{_('Remote Login')}}</h2>
<p>
{{_('Using your another device, visit')}} <a href="{{verify_url}}">{{verify_url}}</a> {{_('and log in')}}.
</p>
<p>
{{_('Once you do so, you will automatically get logged in on this device.')}}
</p>
<p>
{{_('The link will expire after %s minutes.' % 10)}}
</p>
</div>
{% endblock %}
{% block js %}
<script type="text/javascript">
(function () {
// Poll the server to check if the user has authenticated
var t = setInterval(function () {
$.post('{{url_for("token_verified")}}', { token: '{{token}}' })
.done(function(response) {
if (response.status === 'success') {
// Wait a tick so cookies are updated
setTimeout(function () {
window.location.href = '{{url_for("index")}}';
}, 0);
}
})
.fail(function (xhr) {
clearInterval(t);
var response = JSON.parse(xhr.responseText);
alert(response.message);
});
}, 5000);
})()
</script>
{% endblock %}

View File

@ -11,6 +11,8 @@ import logging
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from flask_babel import gettext as _ from flask_babel import gettext as _
import json 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") 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) 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_calibre_url_base = Column(String)
config_google_drive_watch_changes_response = Column(String) config_google_drive_watch_changes_response = Column(String)
config_columns_to_ignore = Column(String) config_columns_to_ignore = Column(String)
config_remote_login = Column(Boolean)
def __repr__(self): def __repr__(self):
pass 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 '<Token %r>' % self.id
# Class holds all application specific settings in calibre-web # Class holds all application specific settings in calibre-web
class Config: class Config:
def __init__(self): def __init__(self):
@ -299,6 +319,7 @@ class Config:
self.config_columns_to_ignore = data.config_columns_to_ignore self.config_columns_to_ignore = data.config_columns_to_ignore
self.db_configured = bool(self.config_calibre_dir is not None and 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'))) (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 @property
def get_main_dir(self): def get_main_dir(self):
@ -449,6 +470,16 @@ def migrate_Database():
session.commit() session.commit()
if session.query(User).filter(User.role.op('&')(ROLE_ANONYMOUS) == ROLE_ANONYMOUS).first() is None: if session.query(User).filter(User.role.op('&')(ROLE_ANONYMOUS) == ROLE_ANONYMOUS).first() is None:
create_anonymous_user() 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(): def create_default_config():
settings = Settings() settings = Settings()
@ -529,7 +560,9 @@ if not os.path.exists(dbpath):
except Exception: except Exception:
raise raise
else: else:
Base.metadata.create_all(engine)
migrate_Database() migrate_Database()
clean_database()
# Generate global Settings Object accecable from every file # Generate global Settings Object accecable from every file
config = Config() config = Config()

View File

@ -58,6 +58,7 @@ import shutil
import gdriveutils import gdriveutils
import tempfile import tempfile
import hashlib import hashlib
from redirect import redirect_back, is_safe_url
from tornado import version as tornadoVersion from tornado import version as tornadoVersion
@ -372,6 +373,21 @@ def login_required_if_no_ano(func):
return login_required(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 # custom jinja filters
@app.template_filter('shortentitle') @app.template_filter('shortentitle')
def shortentitle_filter(s): def shortentitle_filter(s):
@ -1805,14 +1821,20 @@ def login():
if user and check_password_hash(user.password, form['password']): if user and check_password_hash(user.password, form['password']):
login_user(user, remember=True) login_user(user, remember=True)
flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") 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: else:
ipAdress=request.headers.get('X-Forwarded-For', request.remote_addr) ipAdress=request.headers.get('X-Forwarded-For', request.remote_addr)
app.logger.info('Login failed for user "' + form['username'] + '" IP-adress: ' + ipAdress) app.logger.info('Login failed for user "' + form['username'] + '" IP-adress: ' + ipAdress)
flash(_(u"Wrong Username or Password"), category="error") 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') @app.route('/logout')
@ -1823,6 +1845,87 @@ def logout():
return redirect(url_for('login')) 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/<token>')
@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/<int:book_id>') @app.route('/send/<int:book_id>')
@login_required @login_required
@download_required @download_required
@ -2178,6 +2281,10 @@ def configuration_helper(origin):
content.config_anonbrowse = 1 content.config_anonbrowse = 1
if "config_public_reg" in to_save and to_save["config_public_reg"] == "on": if "config_public_reg" in to_save and to_save["config_public_reg"] == "on":
content.config_public_reg = 1 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 content.config_default_role = 0
if "admin_role" in to_save: if "admin_role" in to_save:

View File

@ -26,6 +26,7 @@ Calibre Web is a web app providing a clean interface for browsing, reading and d
- Support for Calibre custom columns - Support for Calibre custom columns
- Fine grained per-user permissions - Fine grained per-user permissions
- Self update capability - Self update capability
- "Magic Link" login to make it easy to log on eReaders
## Quick start ## Quick start
@ -55,6 +56,9 @@ Tick to allow not logged in users to browse the catalog, anonymous user permissi
Enable uploading: Enable uploading:
Tick to enable uploading of PDF, epub, FB2. This requires the imagemagick library to be installed. 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 ## Requirements
Python 2.7+ Python 2.7+