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:
		
							parent
							
								
									040d7d9ae3
								
							
						
					
					
						commit
						27d084ce39
					
				
							
								
								
									
										2
									
								
								cps.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								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() | ||||
|  |  | |||
							
								
								
									
										11
									
								
								cps/admin.py
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								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() | ||||
|  |  | |||
							
								
								
									
										17
									
								
								cps/kobo.py
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								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/<auth_token>') | ||||
| 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") | ||||
|  |  | |||
|  | @ -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: | |||
|     <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. | ||||
|  | @ -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/<auth_token>/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 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 | ||||
| 
 | ||||
| USER_KEY_HEADER = "x-kobo-userkey" | ||||
| USER_KEY_URL_PARAM = "kobo_userkey" | ||||
| 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)) | ||||
|  | @ -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 }}"> | ||||
|     </div> | ||||
|     <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> | ||||
|         <select name="locale" id="locale" class="form-control"> | ||||
|             {%  for translation in translations %} | ||||
|  |  | |||
							
								
								
									
										14
									
								
								cps/ub.py
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								cps/ub.py
									
									
									
									
									
								
							|  | @ -173,7 +173,6 @@ class User(UserBase, Base): | |||
|     role = Column(SmallInteger, default=constants.ROLE_USER) | ||||
|     password = Column(String) | ||||
|     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') | ||||
|     downloads = relationship('Downloads', backref='user', lazy='dynamic') | ||||
|     locale = Column(String(2), default="en") | ||||
|  | @ -308,7 +307,7 @@ class RemoteAuthToken(Base): | |||
|     __tablename__ = 'remote_auth_token' | ||||
| 
 | ||||
|     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')) | ||||
|     verified = Column(Boolean, default=False) | ||||
|     expiration = Column(DateTime) | ||||
|  | @ -376,12 +375,6 @@ def migrate_Database(session): | |||
|     except exc.OperationalError: | ||||
|         conn = engine.connect() | ||||
|         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: | ||||
|         create_anonymous_user(session) | ||||
|     try: | ||||
|  | @ -397,7 +390,6 @@ def migrate_Database(session): | |||
|                             "role SMALLINT," | ||||
|                             "password VARCHAR," | ||||
|                             "kindle_mail VARCHAR(120)," | ||||
|                             "kobo_user_key_hash VARCHAR," | ||||
|                             "locale VARCHAR(2)," | ||||
|                             "sidebar_view INTEGER," | ||||
|                             "default_language VARCHAR(3)," | ||||
|  | @ -405,9 +397,9 @@ def migrate_Database(session): | |||
|                             "UNIQUE (nickname)," | ||||
|                             "UNIQUE (email)," | ||||
|                             "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) " | ||||
|                      "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") | ||||
|         # delete old user table and rename new user_id table to user: | ||||
|         conn.execute("DROP TABLE user") | ||||
|  |  | |||
|  | @ -1254,8 +1254,6 @@ def profile(): | |||
|                 current_user.password = generate_password_hash(to_save["password"]) | ||||
|         if "kindle_mail" in to_save and to_save["kindle_mail"] != current_user.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 config.config_public_reg and not check_valid_domain(to_save["email"]): | ||||
|                 flash(_(u"E-mail is not from valid domain"), category="error") | ||||
|  | @ -1299,7 +1297,7 @@ def profile(): | |||
|             ub.session.commit() | ||||
|         except IntegrityError: | ||||
|             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, | ||||
|                                          translations=translations, | ||||
|                                          title=_(u"%(name)s's profile", name=current_user.nickname), page="me", | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user