1
0
mirror of https://github.com/janeczku/calibre-web synced 2024-12-25 09:30:31 +00:00

Remove the KoboUserKey-based Authentication.

Instead, the user generates the api_endpoint url to set on their device
by visiting http://.../kobo_auth/generate_auth_token.
The generated url will contain a RemoteAuthorizationToken that will be
included on all subsequent requests from the device to the kobo/
endpoints. (In contrast, the device is authenticated using a session cookie on
requests to the download endpoint).

Also use Flask.url_for to generate download urls.
This commit is contained in:
Michael Shavit 2019-12-20 00:47:29 -05:00
parent 040d7d9ae3
commit 27d084ce39
7 changed files with 64 additions and 76 deletions

2
cps.py
View File

@ -42,6 +42,7 @@ from cps.admin import admi
from cps.gdrive import gdrive from cps.gdrive import gdrive
from cps.editbooks import editbook from cps.editbooks import editbook
from cps.kobo import kobo from cps.kobo import kobo
from cps.kobo_auth import kobo_auth
try: try:
from cps.oauth_bb import oauth from cps.oauth_bb import oauth
@ -61,6 +62,7 @@ def main():
app.register_blueprint(gdrive) app.register_blueprint(gdrive)
app.register_blueprint(editbook) app.register_blueprint(editbook)
app.register_blueprint(kobo) app.register_blueprint(kobo)
app.register_blueprint(kobo_auth)
if oauth_available: if oauth_available:
app.register_blueprint(oauth) app.register_blueprint(oauth)
success = web_server.start() success = web_server.start()

View File

@ -596,17 +596,6 @@ def edit_user(user_id):
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()

View File

@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # 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 # 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 # 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 time import gmtime, strftime
from jsonschema import validate, exceptions 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 flask_login import login_required
from sqlalchemy import func from sqlalchemy import func
from . import config, logger, kobo_auth, db, helper from . import config, logger, kobo_auth, db, helper
from .web import download_required from .web import download_required
kobo = Blueprint("kobo", __name__) kobo = Blueprint("kobo", __name__, url_prefix='/kobo/<auth_token>')
kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo) kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo)
kobo_auth.register_url_value_preprocessor(kobo)
log = logger.create() log = logger.create()
@ -216,11 +218,7 @@ def HandleMetadataRequest(book_uuid):
def get_download_url_for_book(book, book_format): def get_download_url_for_book(book, book_format):
return "{url_base}/download/{book_id}/{book_format}".format( return url_for("web.download_link", book_id=book.id, book_format=book_format.lower(), _external = True)
url_base=config.config_server_url,
book_id=book.id,
book_format=book_format.lower(),
)
def create_book_entitlement(book): def create_book_entitlement(book):
@ -352,6 +350,9 @@ def HandleCoverImageRequest(book_uuid, horizontal, vertical, jpeg_quality, monoc
return make_response() return make_response()
return book_cover return book_cover
@kobo.route("")
def TopLevelEndpoint():
return make_response(jsonify({}))
@kobo.route("/v1/user/profile") @kobo.route("/v1/user/profile")
@kobo.route("/v1/user/loyalty/benefits") @kobo.route("/v1/user/loyalty/benefits")

View File

@ -2,7 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web) # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -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,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. * 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 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 from . import logger, ub, lm
from .web import render_title_template
USER_KEY_HEADER = "x-kobo-userkey"
USER_KEY_URL_PARAM = "kobo_userkey"
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
@ -88,15 +82,31 @@ def disable_failed_auth_redirect_for_blueprint(bp):
@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).join(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == auth_token).first()
ub.session.query(ub.User).filter(ub.User.kobo_user_key_hash != "").all() if user is not None:
):
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) login_user(user)
return user return user
log.info("Received Kobo request without a recognizable UserKey.") 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))

View File

@ -28,10 +28,6 @@
<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 %}

View File

@ -173,7 +173,6 @@ 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")
@ -308,7 +307,7 @@ class RemoteAuthToken(Base):
__tablename__ = 'remote_auth_token' __tablename__ = 'remote_auth_token'
id = Column(Integer, primary_key=True) 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')) user_id = Column(Integer, ForeignKey('user.id'))
verified = Column(Boolean, default=False) verified = Column(Boolean, default=False)
expiration = Column(DateTime) expiration = Column(DateTime)
@ -376,12 +375,6 @@ 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:
@ -397,7 +390,6 @@ 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),"
@ -405,9 +397,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,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) " "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") "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")

View File

@ -1254,8 +1254,6 @@ 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")
@ -1299,7 +1297,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 or Kobo UserKey."), category="error") flash(_(u"Found an existing account for this e-mail address."), 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",