mirror of
https://github.com/janeczku/calibre-web
synced 2024-12-25 09:30:31 +00:00
Remove the KoboUserKey-based Authentication.
Instead, the user generates the api_endpoint url to set on their device by visiting http://.../kobo_auth/generate_auth_token. The generated url will contain a RemoteAuthorizationToken that will be included on all subsequent requests from the device to the kobo/ endpoints. (In contrast, the device is authenticated using a session cookie on requests to the download endpoint). Also use Flask.url_for to generate download urls.
This commit is contained in:
parent
040d7d9ae3
commit
27d084ce39
2
cps.py
2
cps.py
@ -42,6 +42,7 @@ from cps.admin import admi
|
|||||||
from cps.gdrive import gdrive
|
from cps.gdrive import gdrive
|
||||||
from cps.editbooks import editbook
|
from cps.editbooks import editbook
|
||||||
from cps.kobo import kobo
|
from cps.kobo import kobo
|
||||||
|
from cps.kobo_auth import kobo_auth
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from cps.oauth_bb import oauth
|
from cps.oauth_bb import oauth
|
||||||
@ -61,6 +62,7 @@ def main():
|
|||||||
app.register_blueprint(gdrive)
|
app.register_blueprint(gdrive)
|
||||||
app.register_blueprint(editbook)
|
app.register_blueprint(editbook)
|
||||||
app.register_blueprint(kobo)
|
app.register_blueprint(kobo)
|
||||||
|
app.register_blueprint(kobo_auth)
|
||||||
if oauth_available:
|
if oauth_available:
|
||||||
app.register_blueprint(oauth)
|
app.register_blueprint(oauth)
|
||||||
success = web_server.start()
|
success = web_server.start()
|
||||||
|
11
cps/admin.py
11
cps/admin.py
@ -596,17 +596,6 @@ def edit_user(user_id):
|
|||||||
if "locale" in to_save and to_save["locale"]:
|
if "locale" in to_save and to_save["locale"]:
|
||||||
content.locale = to_save["locale"]
|
content.locale = to_save["locale"]
|
||||||
|
|
||||||
if "kobo_user_key" in to_save and to_save["kobo_user_key"]:
|
|
||||||
kobo_user_key_hash = generate_password_hash(to_save["kobo_user_key"])
|
|
||||||
if kobo_user_key_hash != content.kobo_user_key_hash:
|
|
||||||
existing_kobo_user_key = ub.session.query(ub.User).filter(ub.User.kobo_user_key_hash == kobo_user_key_hash).first()
|
|
||||||
if not existing_kobo_user_key:
|
|
||||||
content.kobo_user_key_hash = kobo_user_key_hash
|
|
||||||
else:
|
|
||||||
flash(_(u"Found an existing account for this Kobo UserKey."), category="error")
|
|
||||||
return render_title_template("user_edit.html", translations=translations, languages=languages,
|
|
||||||
new_user=0, content=content, downloads=downloads, registered_oauth=oauth_check,
|
|
||||||
title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser")
|
|
||||||
if to_save["email"] and to_save["email"] != content.email:
|
if to_save["email"] and to_save["email"] != content.email:
|
||||||
existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()) \
|
existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()) \
|
||||||
.first()
|
.first()
|
||||||
|
17
cps/kobo.py
17
cps/kobo.py
@ -2,7 +2,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
# Copyright (C) 2018-2019 shavitmichael, OzzieIsaacs
|
# Copyright (C) 2018-2019 OzzieIsaacs
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
@ -25,15 +25,17 @@ from datetime import datetime
|
|||||||
from time import gmtime, strftime
|
from time import gmtime, strftime
|
||||||
|
|
||||||
from jsonschema import validate, exceptions
|
from jsonschema import validate, exceptions
|
||||||
from flask import Blueprint, request, make_response, jsonify, json
|
from flask import Blueprint, request, make_response, jsonify, json, current_app, url_for
|
||||||
|
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
from . import config, logger, kobo_auth, db, helper
|
from . import config, logger, kobo_auth, db, helper
|
||||||
from .web import download_required
|
from .web import download_required
|
||||||
|
|
||||||
kobo = Blueprint("kobo", __name__)
|
kobo = Blueprint("kobo", __name__, url_prefix='/kobo/<auth_token>')
|
||||||
kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo)
|
kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo)
|
||||||
|
kobo_auth.register_url_value_preprocessor(kobo)
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
@ -216,11 +218,7 @@ def HandleMetadataRequest(book_uuid):
|
|||||||
|
|
||||||
|
|
||||||
def get_download_url_for_book(book, book_format):
|
def get_download_url_for_book(book, book_format):
|
||||||
return "{url_base}/download/{book_id}/{book_format}".format(
|
return url_for("web.download_link", book_id=book.id, book_format=book_format.lower(), _external = True)
|
||||||
url_base=config.config_server_url,
|
|
||||||
book_id=book.id,
|
|
||||||
book_format=book_format.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_book_entitlement(book):
|
def create_book_entitlement(book):
|
||||||
@ -352,6 +350,9 @@ def HandleCoverImageRequest(book_uuid, horizontal, vertical, jpeg_quality, monoc
|
|||||||
return make_response()
|
return make_response()
|
||||||
return book_cover
|
return book_cover
|
||||||
|
|
||||||
|
@kobo.route("")
|
||||||
|
def TopLevelEndpoint():
|
||||||
|
return make_response(jsonify({}))
|
||||||
|
|
||||||
@kobo.route("/v1/user/profile")
|
@kobo.route("/v1/user/profile")
|
||||||
@kobo.route("/v1/user/loyalty/benefits")
|
@kobo.route("/v1/user/loyalty/benefits")
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
# Copyright (C) 2018-2019 shavitmichael, OzzieIsaacs
|
# Copyright (C) 2018-2019 OzzieIsaacs
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
@ -29,7 +29,6 @@ which serves the following response:
|
|||||||
<script type='text/javascript'>location.href='kobo://UserAuthenticated?userId=<redacted>&userKey<redacted>&email=<redacted>&returnUrl=https%3a%2f%2fwww.kobo.com';</script>.
|
<script type='text/javascript'>location.href='kobo://UserAuthenticated?userId=<redacted>&userKey<redacted>&email=<redacted>&returnUrl=https%3a%2f%2fwww.kobo.com';</script>.
|
||||||
And triggers the insertion of a userKey into the device's User table.
|
And triggers the insertion of a userKey into the device's User table.
|
||||||
|
|
||||||
IMPORTANT SECURITY CAUTION:
|
|
||||||
Together, the device's DeviceId and UserKey act as an *irrevocable* authentication
|
Together, the device's DeviceId and UserKey act as an *irrevocable* authentication
|
||||||
token to most (if not all) Kobo APIs. In fact, in most cases only the UserKey is
|
token to most (if not all) Kobo APIs. In fact, in most cases only the UserKey is
|
||||||
required to authorize the API call.
|
required to authorize the API call.
|
||||||
@ -48,39 +47,34 @@ v1/auth/device endpoint with the secret UserKey and the device's DeviceId.
|
|||||||
* The book download endpoint passes an auth token as a URL param instead of a header.
|
* The book download endpoint passes an auth token as a URL param instead of a header.
|
||||||
|
|
||||||
Our implementation:
|
Our implementation:
|
||||||
For now, we rely on the official Kobo store's UserKey for authentication.
|
We pretty much ignore all of the above. To authenticate the user, we generate a random
|
||||||
Once authenticated, we set the login cookie on the response that will be sent back for
|
and unique token that they append to the CalibreWeb Url when setting up the api_store
|
||||||
the duration of the session to authorize subsequent API calls.
|
setting on the device.
|
||||||
Ideally we'd only perform UserKey-based authentication for the v1/initialization or the
|
Thus, every request from the device to the api_store will hit CalibreWeb with the
|
||||||
v1/device/auth call, however sessions don't always start with those calls.
|
auth_token in the url (e.g: https://mylibrary.com/<auth_token>/v1/library/sync).
|
||||||
|
In addition, once authenticated we also set the login cookie on the response that will
|
||||||
Because of the irrevocable power granted by the key, we only ever store and compare a
|
be sent back for the duration of the session to authorize subsequent API calls (in
|
||||||
hash of the key. To obtain their UserKey, a user can query the user table from the
|
particular calls to non-Kobo specific endpoints such as the CalibreWeb book download).
|
||||||
.kobo/KoboReader.sqlite database found on their device.
|
|
||||||
This isn't exactly user friendly however.
|
|
||||||
|
|
||||||
Some possible alternatives that require more research:
|
|
||||||
* Instead of having users query the device database to find out their UserKey, we could
|
|
||||||
provide a list of recent Kobo sync attempts in the calibre-web UI for users to
|
|
||||||
authenticate sync attempts (e.g: 'this was me' button).
|
|
||||||
* We may be able to craft a sign-in flow with a redirect back to the CalibreWeb
|
|
||||||
server containing the KoboStore's UserKey (if the same as the devices?).
|
|
||||||
* Can we create our own UserKey instead of relying on the real store's userkey?
|
|
||||||
(Maybe using something like location.href=kobo://UserAuthenticated?userId=...?)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from functools import wraps
|
from binascii import hexlify
|
||||||
from flask import request, make_response
|
from datetime import datetime
|
||||||
from flask_login import login_user
|
from os import urandom
|
||||||
from werkzeug.security import check_password_hash
|
|
||||||
|
from flask import g, Blueprint, url_for
|
||||||
|
from flask_login import login_user, current_user, login_required
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from . import logger, ub, lm
|
from . import logger, ub, lm
|
||||||
|
from .web import render_title_template
|
||||||
USER_KEY_HEADER = "x-kobo-userkey"
|
|
||||||
USER_KEY_URL_PARAM = "kobo_userkey"
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
def register_url_value_preprocessor(kobo):
|
||||||
|
@kobo.url_value_preprocessor
|
||||||
|
def pop_auth_token(endpoint, values):
|
||||||
|
g.auth_token = values.pop('auth_token')
|
||||||
|
|
||||||
|
|
||||||
def disable_failed_auth_redirect_for_blueprint(bp):
|
def disable_failed_auth_redirect_for_blueprint(bp):
|
||||||
lm.blueprint_login_views[bp.name] = None
|
lm.blueprint_login_views[bp.name] = None
|
||||||
@ -88,15 +82,31 @@ def disable_failed_auth_redirect_for_blueprint(bp):
|
|||||||
|
|
||||||
@lm.request_loader
|
@lm.request_loader
|
||||||
def load_user_from_kobo_request(request):
|
def load_user_from_kobo_request(request):
|
||||||
user_key = request.headers.get(USER_KEY_HEADER)
|
if 'auth_token' in g:
|
||||||
if user_key:
|
auth_token = g.get('auth_token')
|
||||||
for user in (
|
user = ub.session.query(ub.User).join(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == auth_token).first()
|
||||||
ub.session.query(ub.User).filter(ub.User.kobo_user_key_hash != "").all()
|
if user is not None:
|
||||||
):
|
|
||||||
if check_password_hash(str(user.kobo_user_key_hash), user_key):
|
|
||||||
# The Kobo device won't preserve the cookie accross sessions, even if we
|
|
||||||
# were to set remember_me=true.
|
|
||||||
login_user(user)
|
login_user(user)
|
||||||
return user
|
return user
|
||||||
log.info("Received Kobo request without a recognizable UserKey.")
|
log.info("Received Kobo request without a recognizable auth token.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix='/kobo_auth')
|
||||||
|
|
||||||
|
@kobo_auth.route('/generate_auth_token')
|
||||||
|
@login_required
|
||||||
|
def generate_auth_token():
|
||||||
|
# Invalidate any prevously generated Kobo Auth token for this user.
|
||||||
|
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == current_user.id).delete()
|
||||||
|
|
||||||
|
auth_token = ub.RemoteAuthToken()
|
||||||
|
auth_token.user_id = current_user.id
|
||||||
|
auth_token.expiration = datetime.max
|
||||||
|
auth_token.auth_token = (hexlify(urandom(16))).decode('utf-8')
|
||||||
|
|
||||||
|
ub.session.add(auth_token)
|
||||||
|
ub.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
return render_title_template('generate_kobo_auth_url.html', title=_(u"Kobo Set-up"),
|
||||||
|
kobo_auth_url=url_for("kobo.TopLevelEndpoint", auth_token=auth_token.auth_token, _external=True))
|
@ -28,10 +28,6 @@
|
|||||||
<input type="email" class="form-control" name="kindle_mail" id="kindle_mail" value="{{ content.kindle_mail if content.kindle_mail != None }}">
|
<input type="email" class="form-control" name="kindle_mail" id="kindle_mail" value="{{ content.kindle_mail if content.kindle_mail != None }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="form-group">
|
|
||||||
<label for="kobo_user_key">{{_('KoboStore UserKey')}}</label>
|
|
||||||
<input type="password" class="form-control" name="kobo_user_key" id="kobo_user_key" value="" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<label for="locale">{{_('Language')}}</label>
|
<label for="locale">{{_('Language')}}</label>
|
||||||
<select name="locale" id="locale" class="form-control">
|
<select name="locale" id="locale" class="form-control">
|
||||||
{% for translation in translations %}
|
{% for translation in translations %}
|
||||||
|
14
cps/ub.py
14
cps/ub.py
@ -173,7 +173,6 @@ class User(UserBase, Base):
|
|||||||
role = Column(SmallInteger, default=constants.ROLE_USER)
|
role = Column(SmallInteger, default=constants.ROLE_USER)
|
||||||
password = Column(String)
|
password = Column(String)
|
||||||
kindle_mail = Column(String(120), default="")
|
kindle_mail = Column(String(120), default="")
|
||||||
kobo_user_key_hash = Column(String, unique=True, default="")
|
|
||||||
shelf = relationship('Shelf', backref='user', lazy='dynamic', order_by='Shelf.name')
|
shelf = relationship('Shelf', backref='user', lazy='dynamic', order_by='Shelf.name')
|
||||||
downloads = relationship('Downloads', backref='user', lazy='dynamic')
|
downloads = relationship('Downloads', backref='user', lazy='dynamic')
|
||||||
locale = Column(String(2), default="en")
|
locale = Column(String(2), default="en")
|
||||||
@ -308,7 +307,7 @@ class RemoteAuthToken(Base):
|
|||||||
__tablename__ = 'remote_auth_token'
|
__tablename__ = 'remote_auth_token'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
auth_token = Column(String(8), unique=True)
|
auth_token = Column(String, unique=True)
|
||||||
user_id = Column(Integer, ForeignKey('user.id'))
|
user_id = Column(Integer, ForeignKey('user.id'))
|
||||||
verified = Column(Boolean, default=False)
|
verified = Column(Boolean, default=False)
|
||||||
expiration = Column(DateTime)
|
expiration = Column(DateTime)
|
||||||
@ -376,12 +375,6 @@ def migrate_Database(session):
|
|||||||
except exc.OperationalError:
|
except exc.OperationalError:
|
||||||
conn = engine.connect()
|
conn = engine.connect()
|
||||||
conn.execute("ALTER TABLE user ADD column `mature_content` INTEGER DEFAULT 1")
|
conn.execute("ALTER TABLE user ADD column `mature_content` INTEGER DEFAULT 1")
|
||||||
try:
|
|
||||||
session.query(exists().where(User.kobo_user_key_hash)).scalar()
|
|
||||||
except exc.OperationalError: # Database is not compatible, some rows are missing
|
|
||||||
conn = engine.connect()
|
|
||||||
conn.execute("ALTER TABLE user ADD column `kobo_user_key_hash` VARCHAR")
|
|
||||||
session.commit()
|
|
||||||
if session.query(User).filter(User.role.op('&')(constants.ROLE_ANONYMOUS) == constants.ROLE_ANONYMOUS).first() is None:
|
if session.query(User).filter(User.role.op('&')(constants.ROLE_ANONYMOUS) == constants.ROLE_ANONYMOUS).first() is None:
|
||||||
create_anonymous_user(session)
|
create_anonymous_user(session)
|
||||||
try:
|
try:
|
||||||
@ -397,7 +390,6 @@ def migrate_Database(session):
|
|||||||
"role SMALLINT,"
|
"role SMALLINT,"
|
||||||
"password VARCHAR,"
|
"password VARCHAR,"
|
||||||
"kindle_mail VARCHAR(120),"
|
"kindle_mail VARCHAR(120),"
|
||||||
"kobo_user_key_hash VARCHAR,"
|
|
||||||
"locale VARCHAR(2),"
|
"locale VARCHAR(2),"
|
||||||
"sidebar_view INTEGER,"
|
"sidebar_view INTEGER,"
|
||||||
"default_language VARCHAR(3),"
|
"default_language VARCHAR(3),"
|
||||||
@ -405,9 +397,9 @@ def migrate_Database(session):
|
|||||||
"UNIQUE (nickname),"
|
"UNIQUE (nickname),"
|
||||||
"UNIQUE (email),"
|
"UNIQUE (email),"
|
||||||
"CHECK (mature_content IN (0, 1)))")
|
"CHECK (mature_content IN (0, 1)))")
|
||||||
conn.execute("INSERT INTO user_id(id, nickname, email, role, password, kindle_mail,kobo_user_key_hash, locale,"
|
conn.execute("INSERT INTO user_id(id, nickname, email, role, password, kindle_mail,locale,"
|
||||||
"sidebar_view, default_language, mature_content) "
|
"sidebar_view, default_language, mature_content) "
|
||||||
"SELECT id, nickname, email, role, password, kindle_mail, kobo_user_key_hash, locale,"
|
"SELECT id, nickname, email, role, password, kindle_mail, locale,"
|
||||||
"sidebar_view, default_language, mature_content FROM user")
|
"sidebar_view, default_language, mature_content FROM user")
|
||||||
# delete old user table and rename new user_id table to user:
|
# delete old user table and rename new user_id table to user:
|
||||||
conn.execute("DROP TABLE user")
|
conn.execute("DROP TABLE user")
|
||||||
|
@ -1254,8 +1254,6 @@ def profile():
|
|||||||
current_user.password = generate_password_hash(to_save["password"])
|
current_user.password = generate_password_hash(to_save["password"])
|
||||||
if "kindle_mail" in to_save and to_save["kindle_mail"] != current_user.kindle_mail:
|
if "kindle_mail" in to_save and to_save["kindle_mail"] != current_user.kindle_mail:
|
||||||
current_user.kindle_mail = to_save["kindle_mail"]
|
current_user.kindle_mail = to_save["kindle_mail"]
|
||||||
if "kobo_user_key" in to_save and to_save["kobo_user_key"]:
|
|
||||||
current_user.kobo_user_key_hash = generate_password_hash(to_save["kobo_user_key"])
|
|
||||||
if to_save["email"] and to_save["email"] != current_user.email:
|
if to_save["email"] and to_save["email"] != current_user.email:
|
||||||
if config.config_public_reg and not check_valid_domain(to_save["email"]):
|
if config.config_public_reg and not check_valid_domain(to_save["email"]):
|
||||||
flash(_(u"E-mail is not from valid domain"), category="error")
|
flash(_(u"E-mail is not from valid domain"), category="error")
|
||||||
@ -1299,7 +1297,7 @@ def profile():
|
|||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
flash(_(u"Found an existing account for this e-mail address or Kobo UserKey."), category="error")
|
flash(_(u"Found an existing account for this e-mail address."), category="error")
|
||||||
return render_title_template("user_edit.html", content=current_user, downloads=downloads,
|
return render_title_template("user_edit.html", content=current_user, downloads=downloads,
|
||||||
translations=translations,
|
translations=translations,
|
||||||
title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
|
title=_(u"%(name)s's profile", name=current_user.nickname), page="me",
|
||||||
|
Loading…
Reference in New Issue
Block a user