* 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).
This commit is contained in:
parent
55b54de6a0
commit
9ede01f130
13
cps/admin.py
13
cps/admin.py
|
@ -565,7 +565,6 @@ def edit_user(user_id):
|
||||||
else:
|
else:
|
||||||
if "password" in to_save and to_save["password"]:
|
if "password" in to_save and to_save["password"]:
|
||||||
content.password = generate_password_hash(to_save["password"])
|
content.password = generate_password_hash(to_save["password"])
|
||||||
|
|
||||||
anonymous = content.is_anonymous
|
anonymous = content.is_anonymous
|
||||||
content.role = constants.selected_roles(to_save)
|
content.role = constants.selected_roles(to_save)
|
||||||
if anonymous:
|
if anonymous:
|
||||||
|
@ -593,6 +592,18 @@ def edit_user(user_id):
|
||||||
content.default_language = to_save["default_language"]
|
content.default_language = to_save["default_language"]
|
||||||
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()
|
||||||
|
|
13
cps/kobo.py
13
cps/kobo.py
|
@ -2,9 +2,8 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from flask import Blueprint, request, flash, redirect, url_for
|
from flask import Blueprint, request, flash, redirect, url_for
|
||||||
from . import logger, ub, searched_ids, db, helper
|
from . import config, logger, kobo_auth, ub, db, helper
|
||||||
from . import config
|
from .web import download_required
|
||||||
|
|
||||||
from flask import make_response
|
from flask import make_response
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
from flask import json
|
from flask import json
|
||||||
|
@ -22,15 +21,17 @@ from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||||
import copy
|
import copy
|
||||||
import jsonschema
|
import jsonschema
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
from flask_login import login_required
|
||||||
|
|
||||||
B2_SECRETS = os.path.join(_CONFIG_DIR, "b2_secrets.json")
|
B2_SECRETS = os.path.join(_CONFIG_DIR, "b2_secrets.json")
|
||||||
|
|
||||||
kobo = Blueprint("kobo", __name__)
|
kobo = Blueprint("kobo", __name__)
|
||||||
|
kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo)
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
|
||||||
def b64encode(data):
|
def b64encode(data):
|
||||||
return base64.b64encode(data)
|
return base64.b64encode(data)
|
||||||
|
|
||||||
|
@ -143,6 +144,8 @@ class SyncToken:
|
||||||
|
|
||||||
|
|
||||||
@kobo.route("/v1/library/sync")
|
@kobo.route("/v1/library/sync")
|
||||||
|
@login_required
|
||||||
|
@download_required
|
||||||
def HandleSyncRequest():
|
def HandleSyncRequest():
|
||||||
sync_token = SyncToken.from_headers(request.headers)
|
sync_token = SyncToken.from_headers(request.headers)
|
||||||
log.info("Kobo library sync request received.")
|
log.info("Kobo library sync request received.")
|
||||||
|
@ -190,6 +193,8 @@ def HandleSyncRequest():
|
||||||
|
|
||||||
|
|
||||||
@kobo.route("/v1/library/<book_uuid>/metadata")
|
@kobo.route("/v1/library/<book_uuid>/metadata")
|
||||||
|
@login_required
|
||||||
|
@download_required
|
||||||
def get_metadata__v1(book_uuid):
|
def get_metadata__v1(book_uuid):
|
||||||
log.info("Kobo library metadata request received for book %s" % 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()
|
book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first()
|
||||||
|
|
67
cps/kobo_auth.py
Normal file
67
cps/kobo_auth.py
Normal file
|
@ -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=<some id>
|
||||||
|
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>.
|
||||||
|
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
|
|
@ -28,6 +28,10 @@
|
||||||
<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 %}
|
||||||
|
|
13
cps/ub.py
13
cps/ub.py
|
@ -173,6 +173,7 @@ 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")
|
||||||
|
@ -375,7 +376,12 @@ 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:
|
||||||
|
@ -391,6 +397,7 @@ 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),"
|
||||||
|
@ -398,9 +405,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,locale,"
|
conn.execute("INSERT INTO user_id(id, nickname, email, role, password, kindle_mail,kobo_user_key_hash, locale,"
|
||||||
"sidebar_view, default_language, mature_content) "
|
"sidebar_view, default_language, mature_content) "
|
||||||
"SELECT id, nickname, email, role, password, kindle_mail, locale,"
|
"SELECT id, nickname, email, role, password, kindle_mail, kobo_user_key_hash, 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")
|
||||||
|
|
|
@ -1246,6 +1246,8 @@ 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")
|
||||||
|
@ -1274,7 +1276,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."), category="error")
|
flash(_(u"Found an existing account for this e-mail address or Kobo UserKey."), 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