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/admin.py b/cps/admin.py index e5c7c002..0354ed2b 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -596,17 +596,6 @@ def edit_user(user_id): if "locale" in to_save and 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: existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()) \ .first() diff --git a/cps/kobo.py b/cps/kobo.py index bc4046d8..8ccec9c9 100644 --- a/cps/kobo.py +++ b/cps/kobo.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # 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 # 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 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 from . import config, logger, kobo_auth, db, helper from .web import download_required -kobo = Blueprint("kobo", __name__) +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() @@ -216,11 +218,7 @@ def HandleMetadataRequest(book_uuid): def get_download_url_for_book(book, book_format): - return "{url_base}/download/{book_id}/{book_format}".format( - url_base=config.config_server_url, - book_id=book.id, - book_format=book_format.lower(), - ) + return url_for("web.download_link", book_id=book.id, book_format=book_format.lower(), _external = True) def create_book_entitlement(book): @@ -352,6 +350,9 @@ def HandleCoverImageRequest(book_uuid, horizontal, vertical, jpeg_quality, monoc return make_response() return book_cover +@kobo.route("") +def TopLevelEndpoint(): + return make_response(jsonify({})) @kobo.route("/v1/user/profile") @kobo.route("/v1/user/loyalty/benefits") diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py index 1504c25b..fef92599 100644 --- a/cps/kobo_auth.py +++ b/cps/kobo_auth.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # 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 # it under the terms of the GNU General Public License as published by @@ -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,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. 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 . import logger, ub, lm +from flask import g, Blueprint, url_for +from flask_login import login_user, current_user, login_required +from flask_babel import gettext as _ -USER_KEY_HEADER = "x-kobo-userkey" -USER_KEY_URL_PARAM = "kobo_userkey" +from . import logger, ub, lm +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 @@ -88,15 +82,31 @@ def disable_failed_auth_redirect_for_blueprint(bp): @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)) \ No newline at end of file diff --git a/cps/templates/user_edit.html b/cps/templates/user_edit.html index 5ace1eab..e4e36c64 100644 --- a/cps/templates/user_edit.html +++ b/cps/templates/user_edit.html @@ -28,10 +28,6 @@
-
- - -