From 9ede01f130269694efad9fa813626e642db60b8a Mon Sep 17 00:00:00 2001 From: Michael Shavit Date: Sat, 7 Dec 2019 19:21:08 -0500 Subject: [PATCH] * Add a UserKeyToken to the User table for Kobo authorization. * Add proper authorization checks on the new Kobo endpoints. Important Note: As a side-effect, all CalibreWeb API calls can be authorized using this token (i.e without a username&password). --- cps/admin.py | 13 ++++++- cps/kobo.py | 13 ++++--- cps/kobo_auth.py | 67 ++++++++++++++++++++++++++++++++++++ cps/templates/user_edit.html | 4 +++ cps/ub.py | 13 +++++-- cps/web.py | 4 ++- 6 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 cps/kobo_auth.py diff --git a/cps/admin.py b/cps/admin.py index 0e30109c..0292eee3 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -565,7 +565,6 @@ def edit_user(user_id): else: if "password" in to_save and to_save["password"]: content.password = generate_password_hash(to_save["password"]) - anonymous = content.is_anonymous content.role = constants.selected_roles(to_save) if anonymous: @@ -593,6 +592,18 @@ def edit_user(user_id): content.default_language = to_save["default_language"] 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 c41b6a4c..d78e6f19 100644 --- a/cps/kobo.py +++ b/cps/kobo.py @@ -2,9 +2,8 @@ # -*- coding: utf-8 -*- from flask import Blueprint, request, flash, redirect, url_for -from . import logger, ub, searched_ids, db, helper -from . import config - +from . import config, logger, kobo_auth, ub, db, helper +from .web import download_required from flask import make_response from flask import jsonify from flask import json @@ -22,15 +21,17 @@ from .constants import CONFIG_DIR as _CONFIG_DIR import copy import jsonschema from sqlalchemy import func +from flask_login import login_required B2_SECRETS = os.path.join(_CONFIG_DIR, "b2_secrets.json") kobo = Blueprint("kobo", __name__) +kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo) + log = logger.create() import base64 - def b64encode(data): return base64.b64encode(data) @@ -143,6 +144,8 @@ class SyncToken: @kobo.route("/v1/library/sync") +@login_required +@download_required def HandleSyncRequest(): sync_token = SyncToken.from_headers(request.headers) log.info("Kobo library sync request received.") @@ -190,6 +193,8 @@ def HandleSyncRequest(): @kobo.route("/v1/library//metadata") +@login_required +@download_required def get_metadata__v1(book_uuid): log.info("Kobo library metadata request received for book %s" % book_uuid) book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first() diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py new file mode 100644 index 00000000..c1f45f08 --- /dev/null +++ b/cps/kobo_auth.py @@ -0,0 +1,67 @@ +"""This module is used to control authentication/authorization of Kobo sync requests. +This module also includes research notes into the auth protocol used by Kobo devices. + +Log-in: +When first booting a Kobo device the user must sign into a Kobo (or affiliate) account. +Upon successful sign-in, the user is redirected to + https://auth.kobobooks.com/CrossDomainSignIn?id= +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. + +Changing Kobo password *does not* invalidate user keys! This is apparently a known +issue for a few years now https://www.mobileread.com/forums/showpost.php?p=3476851&postcount=13 +(although this poster hypothesised that Kobo could blacklist a DeviceId, many endpoints +will still grant access given the userkey.) + +Api authorization: +* For most of the endpoints we care about (sync, metadata, tags, etc), the userKey is +passed in the x-kobo-userkey header, and is sufficient to authorize the API call. +* Some endpoints (e.g: AnnotationService) instead make use of Bearer tokens. To get a +BearerToken, the device makes a POST request to the v1/auth/device endpoint with the +secret UserKey and the device's DeviceId. + +Our implementation: +For now, we rely on the official Kobo store's UserKey for authentication. 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. + * 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 flask import request, make_response +from werkzeug.security import check_password_hash +from . import logger, ub, lm + +USER_KEY_HEADER = "x-kobo-userkey" +log = logger.create() + +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): + return user + log.info("Received Kobo request without a recognizable UserKey.") + return None \ No newline at end of file diff --git a/cps/templates/user_edit.html b/cps/templates/user_edit.html index e22a9415..1e7fa9b9 100644 --- a/cps/templates/user_edit.html +++ b/cps/templates/user_edit.html @@ -28,6 +28,10 @@
+
+ + +