diff --git a/cps.py b/cps.py index 412604d2..4daa0d9b 100755 --- a/cps.py +++ b/cps.py @@ -42,6 +42,7 @@ from cps.admin import admi from cps.gdrive import gdrive from cps.editbooks import editbook from cps.kobo import kobo +from cps.kobo_auth import kobo_auth try: from cps.oauth_bb import oauth @@ -61,6 +62,7 @@ def main(): app.register_blueprint(gdrive) app.register_blueprint(editbook) app.register_blueprint(kobo) + app.register_blueprint(kobo_auth) if oauth_available: app.register_blueprint(oauth) success = web_server.start() diff --git a/cps/kobo.py b/cps/kobo.py index 26ec734a..49132f7e 100644 --- a/cps/kobo.py +++ b/cps/kobo.py @@ -25,15 +25,20 @@ from datetime import datetime from time import gmtime, strftime 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 sqlalchemy import func, or_ +from sqlalchemy import func from . import config, logger, kobo_auth, db, helper from .web import download_required -kobo = Blueprint("kobo", __name__) +#TODO: Test more formats :) . +KOBO_SUPPORTED_FORMATS = {"KEPUB"} + +kobo = Blueprint("kobo", __name__, url_prefix="/kobo/") kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo) +kobo_auth.register_url_value_preprocessor(kobo) log = logger.create() @@ -166,9 +171,10 @@ def HandleSyncRequest(): # It looks like it's treating the db.Books.last_modified field as a string and may fail # the comparison because of the +00:00 suffix. changed_entries = ( - db.session.query(db.Books).join(db.Data) - .filter(func.datetime(db.Books.last_modified) != sync_token.books_last_modified) - .filter(or_(db.Data.format == 'KEPUB', db.Data.format == 'EPUB')) + db.session.query(db.Books) + .join(db.Data) + .filter(func.datetime(db.Books.last_modified) > sync_token.books_last_modified) + .filter(db.Data.format.in_(KOBO_SUPPORTED_FORMATS)) .all() ) for book in changed_entries: @@ -217,10 +223,11 @@ def HandleMetadataRequest(book_uuid): def get_download_url_for_book(book, book_format): - return "{url_base}/download/{book_id}/{book_format}".format( - url_base=get_base_url(), # request.environ['werkzeug.request'].base_url, + return url_for( + "web.download_link", book_id=book.id, - book_format="kepub", + book_format=book_format.lower(), + _external=True, ) @@ -273,14 +280,13 @@ def get_series(book): def get_metadata(book): - ALLOWED_FORMATS = {"KEPUB", "EPUB"} download_urls = [] for book_data in book.data: - if book_data.format in ALLOWED_FORMATS: + if book_data.format in KOBO_SUPPORTED_FORMATS: download_urls.append( { - "Format": "KEPUB", + "Format": book_data.format, "Size": book_data.uncompressed_size, "Url": get_download_url_for_book(book, book_data.format), # "DrmType": "None", # Not required @@ -354,9 +360,14 @@ def HandleCoverImageRequest(book_uuid, horizontal, vertical, jpeg_quality, monoc return book_cover +@kobo.route("") +def TopLevelEndpoint(): + return make_response(jsonify({})) + + @kobo.route("/v1/user/profile") @kobo.route("/v1/user/loyalty/benefits") -@kobo.route("/v1/analytics/gettests", methods=["GET", "POST"]) +@kobo.route("/v1/analytics/gettests/", methods=["GET", "POST"]) @kobo.route("/v1/user/wishlist") @kobo.route("/v1/user/") @kobo.route("/v1/user/recommendations") @@ -386,12 +397,11 @@ def HandleAuthRequest(): return response -def get_base_url(): - return "{root}:{port}".format(root=request.url_root[:-1], port=str(config.config_port)) - @kobo.route("/v1/initialization") def HandleInitRequest(): - resources = NATIVE_KOBO_RESOURCES(calibre_web_url=get_base_url()) + resources = NATIVE_KOBO_RESOURCES( + calibre_web_url=url_for("web.index", _external=True).strip("/") + ) response = make_response(jsonify({"Resources": resources})) response.headers["x-kobo-apitoken"] = "e30=" return response diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py index 1504c25b..42fac356 100644 --- a/cps/kobo_auth.py +++ b/cps/kobo_auth.py @@ -29,7 +29,6 @@ which serves the following response: . 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 token to most (if not all) Kobo APIs. In fact, in most cases only the UserKey is required to authorize the API call. @@ -48,55 +47,80 @@ 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. Our implementation: -For now, we rely on the official Kobo store's UserKey for authentication. -Once authenticated, we set the login cookie on the response that will be sent back for -the duration of the session to authorize subsequent API calls. -Ideally we'd only perform UserKey-based authentication for the v1/initialization or the -v1/device/auth call, however sessions don't always start with those calls. - -Because of the irrevocable power granted by the key, we only ever store and compare a -hash of the key. To obtain their UserKey, a user can query the user table from the -.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=...?) +We pretty much ignore all of the above. To authenticate the user, we generate a random +and unique token that they append to the CalibreWeb Url when setting up the api_store +setting on the device. +Thus, every request from the device to the api_store will hit CalibreWeb with the +auth_token in the url (e.g: https://mylibrary.com//v1/library/sync). +In addition, once authenticated we also set the login cookie on the response that will +be sent back for the duration of the session to authorize subsequent API calls (in +particular calls to non-Kobo specific endpoints such as the CalibreWeb book download). """ -from functools import wraps -from flask import request, make_response -from flask_login import login_user -from werkzeug.security import check_password_hash +from binascii import hexlify +from datetime import datetime +from os import urandom + +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 - -USER_KEY_HEADER = "x-kobo-userkey" -USER_KEY_URL_PARAM = "kobo_userkey" +from .web import render_title_template 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): lm.blueprint_login_views[bp.name] = None @lm.request_loader def load_user_from_kobo_request(request): - user_key = request.headers.get(USER_KEY_HEADER) - if user_key: - for user in ( - ub.session.query(ub.User).filter(ub.User.kobo_user_key_hash != "").all() - ): - 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) - return user - log.info("Received Kobo request without a recognizable UserKey.") + if "auth_token" in g: + auth_token = g.get("auth_token") + user = ( + ub.session.query(ub.User) + .join(ub.RemoteAuthToken) + .filter(ub.RemoteAuthToken.auth_token == auth_token) + .first() + ) + if user is not None: + login_user(user) + return user + log.info("Received Kobo request without a recognizable auth token.") 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 + ), + ) diff --git a/cps/templates/generate_kobo_auth_url.html b/cps/templates/generate_kobo_auth_url.html new file mode 100644 index 00000000..28b098cf --- /dev/null +++ b/cps/templates/generate_kobo_auth_url.html @@ -0,0 +1,15 @@ +{% extends "layout.html" %} +{% block body %} +
+

{{_('Generate Kobo Auth URL')}}

+

+ {{_('Open the .kobo/Kobo eReader.conf file in a text editor and add (or edit):')}}. +

+

+ {{_('api_endpoint=')}}{{kobo_auth_url}} +

+

+ {{_('Please note that every visit to this current page invalidates any previously generated Authentication url for this user.')}}. +

+
+{% endblock %} \ No newline at end of file diff --git a/cps/ub.py b/cps/ub.py index 330fdd24..138fd022 100644 --- a/cps/ub.py +++ b/cps/ub.py @@ -173,7 +173,6 @@ class User(UserBase, Base): role = Column(SmallInteger, default=constants.ROLE_USER) password = Column(String) 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') downloads = relationship('Downloads', backref='user', lazy='dynamic') locale = Column(String(2), default="en") @@ -308,7 +307,7 @@ class RemoteAuthToken(Base): __tablename__ = 'remote_auth_token' 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')) verified = Column(Boolean, default=False) expiration = Column(DateTime) @@ -376,12 +375,6 @@ def migrate_Database(session): except exc.OperationalError: conn = engine.connect() 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: create_anonymous_user(session) try: @@ -397,7 +390,6 @@ def migrate_Database(session): "role SMALLINT," "password VARCHAR," "kindle_mail VARCHAR(120)," - "kobo_user_key_hash VARCHAR," "locale VARCHAR(2)," "sidebar_view INTEGER," "default_language VARCHAR(3)," @@ -405,9 +397,9 @@ def migrate_Database(session): "UNIQUE (nickname)," "UNIQUE (email)," "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) " - "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") # delete old user table and rename new user_id table to user: conn.execute("DROP TABLE user")