|
|
@ -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,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.
|
|
|
|
* 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 . 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"
|
|
|
|
from . import logger, ub, lm
|
|
|
|
USER_KEY_URL_PARAM = "kobo_userkey"
|
|
|
|
from .web import render_title_template
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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).filter(ub.User.kobo_user_key_hash != "").all()
|
|
|
|
ub.session.query(ub.User)
|
|
|
|
):
|
|
|
|
.join(ub.RemoteAuthToken)
|
|
|
|
if check_password_hash(str(user.kobo_user_key_hash), user_key):
|
|
|
|
.filter(ub.RemoteAuthToken.auth_token == auth_token)
|
|
|
|
# The Kobo device won't preserve the cookie accross sessions, even if we
|
|
|
|
.first()
|
|
|
|
# were to set remember_me=true.
|
|
|
|
)
|
|
|
|
login_user(user)
|
|
|
|
if user is not None:
|
|
|
|
return user
|
|
|
|
login_user(user)
|
|
|
|
log.info("Received Kobo request without a recognizable UserKey.")
|
|
|
|
return user
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
)
|
|
|
|