Merge remote-tracking branch 'button_padding/magic-link'
This commit is contained in:
commit
5a6ad970d8
8 changed files with 221 additions and 2 deletions
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>{{_('Public registration')}}</th>
|
||||
<th>{{_('Anonymous browsing')}}</th>
|
||||
<th>{{_('Remote Login')}}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<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_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_remote_login %}<span class="glyphicon glyphicon-ok"></span>{% else %}<span class="glyphicon glyphicon-remove"></span>{% endif %}</td>
|
||||
</table>
|
||||
<div class="btn btn-default"><a href="{{url_for('configuration')}}">{{_('Configuration')}}</a></div>
|
||||
<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 %}>
|
||||
<label for="config_public_reg">{{_('Enable public registration')}}</label>
|
||||
</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>
|
||||
<div class="form-group">
|
||||
<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">
|
||||
<h2 style="margin-top: 0">{{_('Login')}}</h2>
|
||||
<form method="POST" role="form">
|
||||
<input type="hidden" name="next" value="{{next_url}}">
|
||||
<div class="form-group">
|
||||
<label for="username">{{_('Username')}}</label>
|
||||
<input type="text" class="form-control" id="username" name="username" placeholder="{{_('Username')}}">
|
||||
|
@ -17,6 +18,9 @@
|
|||
</label>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
{% 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 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 '<Token %r>' % 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()
|
||||
|
|
111
cps/web.py
111
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):
|
||||
|
@ -1809,14 +1825,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')
|
||||
|
@ -1827,6 +1849,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/<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>')
|
||||
@login_required
|
||||
@download_required
|
||||
|
@ -2186,6 +2289,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:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
@ -56,6 +57,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+
|
||||
|
|
Loading…
Reference in a new issue