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:
parent
5276bda153
commit
623f5c8ef0
25
cps/redirect.py
Normal file
25
cps/redirect.py
Normal 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)
|
|
@ -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>
|
||||||
|
|
|
@ -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 %}>
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
40
cps/templates/remote_login.html
Normal file
40
cps/templates/remote_login.html
Normal 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 %}
|
33
cps/ub.py
33
cps/ub.py
|
@ -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()
|
||||||
|
|
111
cps/web.py
111
cps/web.py
|
@ -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:
|
||||||
|
|
|
@ -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+
|
||||||
|
|
Loading…
Reference in New Issue
Block a user