diff --git a/calibre-plugin/dialogs.py b/calibre-plugin/dialogs.py
index d9dfe58d..8b168bc3 100644
--- a/calibre-plugin/dialogs.py
+++ b/calibre-plugin/dialogs.py
@@ -620,6 +620,48 @@ class UserPassDialog(QDialog):
self.status=False
self.hide()
+class TOTPDialog(QDialog):
+ '''
+ Need to collect Timebased One Time Password(TOTP) for some sites.
+ '''
+ def __init__(self, gui, site, exception=None):
+ QDialog.__init__(self, gui)
+ self.status=False
+
+ self.l = QVBoxLayout()
+ self.setLayout(self.l)
+
+ grid = QGridLayout()
+ self.l.addLayout(grid)
+
+ self.setWindowTitle(_('Time-based One Time Password(TOTP)'))
+ grid.addWidget(QLabel(_("Site requires a Time-based One Time Password(TOTP) for this url:\n%s")%exception.url),0,0,1,2)
+
+ grid.addWidget(QLabel(_("TOTP:")),2,0)
+ self.totp = QLineEdit(self)
+ grid.addWidget(self.totp,2,1)
+
+ horz = QHBoxLayout()
+ self.l.addLayout(horz)
+
+ self.ok_button = QPushButton(_('OK'), self)
+ self.ok_button.clicked.connect(self.ok)
+ horz.addWidget(self.ok_button)
+
+ self.cancel_button = QPushButton(_('Cancel'), self)
+ self.cancel_button.clicked.connect(self.cancel)
+ horz.addWidget(self.cancel_button)
+
+ self.resize(self.sizeHint())
+
+ def ok(self):
+ self.status=True
+ self.hide()
+
+ def cancel(self):
+ self.status=False
+ self.hide()
+
def LoopProgressDialog(gui,
book_list,
foreach_function,
diff --git a/calibre-plugin/fff_plugin.py b/calibre-plugin/fff_plugin.py
index b0f10e3d..3e792563 100644
--- a/calibre-plugin/fff_plugin.py
+++ b/calibre-plugin/fff_plugin.py
@@ -108,7 +108,7 @@ from calibre_plugins.fanficfare_plugin.prefs import (
from calibre_plugins.fanficfare_plugin.dialogs import (
AddNewDialog, UpdateExistingDialog,
LoopProgressDialog, UserPassDialog, AboutDialog, CollectURLDialog,
- RejectListDialog, EmailPassDialog,
+ RejectListDialog, EmailPassDialog, TOTPDialog,
save_collisions, question_dialog_all,
NotGoingToDownload, RejectUrlEntry, IniTextDialog)
@@ -1240,9 +1240,9 @@ class FanFicFarePlugin(InterfaceAction):
def get_story_metadata_only(self,adapter):
url = adapter.url
- ## three tries, that's enough if both user/pass & is_adult needed,
- ## or a couple tries of one or the other
- for x in [0,1,2]:
+ ## 5 tries, should be enough if user/pass, totp & is_adult
+ ## needed, or a couple tries of one or the other
+ for x in [0,1,2,3,4]:
try:
adapter.getStoryMetadataOnly(get_cover=False)
except exceptions.FailedToLogin as f:
@@ -1253,6 +1253,13 @@ class FanFicFarePlugin(InterfaceAction):
adapter.username = userpass.user.text()
adapter.password = userpass.passwd.text()
+ except exceptions.NeedTimedOneTimePassword as e:
+ logger.warn("Login Failed, Need Username/Password.")
+ totpdlg = TOTPDialog(self.gui,url,e)
+ totpdlg.exec_() # exec_ will make it act modal
+ if totpdlg.status:
+ adapter.totp = totpdlg.totp.text()
+
except exceptions.AdultCheckRequired:
if question_dialog_all(self.gui, _('Are You an Adult?'), '
'+
_("%s requires that you be an adult. Please confirm you are an adult in your locale:")%url,
@@ -1428,6 +1435,7 @@ class FanFicFarePlugin(InterfaceAction):
book['is_adult'] = adapter.is_adult
book['username'] = adapter.username
book['password'] = adapter.password
+ book['totp'] = adapter.totp
book['icon'] = 'plus.png'
book['status'] = _('Add')
@@ -3110,7 +3118,7 @@ def pretty_book(d, indent=0, spacer=' '):
# return '\n'.join([(pretty_book(v, indent, spacer)) for v in d])
if isinstance(d, dict):
- for k in ('password','username'):
+ for k in ('password','username','totp'):
if k in d and d[k]:
d[k]=_('(was set, removed for security)')
return '\n'.join(['%s%s:\n%s' % (kindent, k, pretty_book(v, indent + 1, spacer))
diff --git a/calibre-plugin/jobs.py b/calibre-plugin/jobs.py
index 9d1ed97c..d02d14be 100644
--- a/calibre-plugin/jobs.py
+++ b/calibre-plugin/jobs.py
@@ -235,6 +235,7 @@ def do_download_for_worker(book,options,merge,notification=lambda x,y:x):
adapter.is_adult = book['is_adult']
adapter.username = book['username']
adapter.password = book['password']
+ adapter.totp = book['totp']
adapter.setChaptersRange(book['begin'],book['end'])
## each site download job starts with a new copy of the
diff --git a/fanficfare/adapters/adapter_storiesonlinenet.py b/fanficfare/adapters/adapter_storiesonlinenet.py
index 2a27ec3c..4b981874 100644
--- a/fanficfare/adapters/adapter_storiesonlinenet.py
+++ b/fanficfare/adapters/adapter_storiesonlinenet.py
@@ -147,6 +147,21 @@ class StoriesOnlineNetAdapter(BaseSiteAdapter):
postAction,
'','',''))
data = self.post_request(postUrl,params,usecache=False)
+ # logger.debug(data)
+ while '
Enter TOTP Code:
' in data:
+ if self.totp:
+ logger.debug("Trying to TOTP with %s code."%self.totp)
+ params = {}
+ params['cmd'] = 'finishTotpVerification'
+ # google auth app at least shows "123 123", but site expects
+ # "123123". Remove space if user enters it.
+ params['totp_code'] = self.totp.replace(' ','')
+ params['action'] = "continue"
+ data = self.post_request(postUrl,params,usecache=False)
+ # logger.debug(data)
+ self.totp = None
+ else:
+ raise exceptions.NeedTimedOneTimePassword(url)
if self.needToLoginCheck(data):
logger.info("Failed to login to URL %s as %s" % (loginUrl,
diff --git a/fanficfare/adapters/adapter_test1.py b/fanficfare/adapters/adapter_test1.py
index bb77099f..33f0ad8d 100644
--- a/fanficfare/adapters/adapter_test1.py
+++ b/fanficfare/adapters/adapter_test1.py
@@ -129,6 +129,9 @@ Some more longer description. "I suck at summaries!" "Better than it sounds!"
else:
self.story.setMetadata('dateUpdated',makeDate("1975-04-15","%Y-%m-%d"))
+ if idstr == '675' and self.totp != "123321" :
+ raise exceptions.NeedTimedOneTimePassword(self.url)
+
if idstr != '674':
self.story.setMetadata('numWords','123456')
diff --git a/fanficfare/adapters/adapter_thesietchcom.py b/fanficfare/adapters/adapter_thesietchcom.py
index 64e06f2b..6f296631 100644
--- a/fanficfare/adapters/adapter_thesietchcom.py
+++ b/fanficfare/adapters/adapter_thesietchcom.py
@@ -45,6 +45,9 @@ class TheSietchComAdapter(BaseXenForo2ForumAdapter):
# in case it needs more than just site/
return '/index.php?'
+ def loginFormMarker(self):
+ return 'href="/index.php?login/"'
+
def make_reader_url(self,tmcat_num,reader_page_num):
# https://www.the-sietch.com/index.php?threads/shattered-sphere-the-arcadian-free-march.3243/reader/page-2
# discard tmcat_num -- the-sietch.com doesn't have multiple
diff --git a/fanficfare/adapters/base_adapter.py b/fanficfare/adapters/base_adapter.py
index 5b64736c..c03148d5 100644
--- a/fanficfare/adapters/base_adapter.py
+++ b/fanficfare/adapters/base_adapter.py
@@ -77,6 +77,7 @@ class BaseSiteAdapter(Requestable):
self.username = "NoneGiven" # if left empty, site doesn't return any message at all.
self.password = ""
+ self.totp = None # Timed One Time Password(TOTP) for 2FA
self.is_adult=False
self.storyDone = False
diff --git a/fanficfare/adapters/base_xenforo2forum_adapter.py b/fanficfare/adapters/base_xenforo2forum_adapter.py
index 3db7aa7e..d8071b20 100644
--- a/fanficfare/adapters/base_xenforo2forum_adapter.py
+++ b/fanficfare/adapters/base_xenforo2forum_adapter.py
@@ -41,10 +41,14 @@ class BaseXenForo2ForumAdapter(BaseXenForoForumAdapter):
"Only needs to be overriden if has additional ini sections."
return super(BaseXenForo2ForumAdapter, cls).getConfigSections() + ['base_xenforo2forum']
+ ## the-sietch.com needs a different value.
+ def loginFormMarker(self):
+ return 'href="/login/"'
+
def performLogin(self,data):
params = {}
- if data and 'href="/login/"' not in data:
+ if data and self.loginFormMarker() not in data:
## already logged in.
logger.debug("Already Logged In")
return
@@ -75,16 +79,40 @@ class BaseXenForo2ForumAdapter(BaseXenForoForumAdapter):
logger.debug("Will now login to URL (%s) as (%s)" % (loginUrl,
params['login']))
- d = self.post_request(loginUrl, params)
+ data = self.post_request(loginUrl, params)
+ # logger.debug(data)
- if "Log in" in d:
- # logger.debug(d)
+ while "Please enter the verification code generated by the app on your phone" in data:
+ logger.info("TOTP required to login to URL %s" % self.url)
+ if self.totp:
+ logger.debug("Trying to TOTP with %s code."%self.totp)
+ postUrl = self.getURLPrefix() + 'login/two-step'
+ totpparams = {}
+ xftoken = data[data.index(find_token)+len(find_token):]
+ xftoken = xftoken[:xftoken.index('"')]
+ totpparams['remember'] = '0'
+ totpparams['confirm'] = '1'
+ totpparams['provider'] = 'totp'
+ totpparams['_xfResponseType'] = 'json'
+ totpparams['_xfToken'] = xftoken
+ totpparams['_xfRedirect'] = self.getURLPrefix()
+ totpparams['_xfWithData'] = '1'
+ # google auth app at least shows "123 123", but site expects
+ # "123123". Remove space if user enters it.
+ totpparams['code'] = self.totp.replace(' ','')
+ data = self.post_request(postUrl,totpparams,usecache=False)
+ # logger.debug(data)
+ self.totp = None
+ else:
+ raise exceptions.NeedTimedOneTimePassword(self.url)
+ return False
+ if "Log in" in data:
+ # logger.debug(data)
logger.info("Failed to login to URL %s as %s" % (self.url,
params['login']))
raise exceptions.FailedToLogin(self.url,params['login'])
return False
- else:
- return True
+ return True
def parse_title(self,souptag):
h1 = souptag.find('h1',{'class':'p-title-value'})
diff --git a/fanficfare/cli.py b/fanficfare/cli.py
index 33610e80..a2801734 100644
--- a/fanficfare/cli.py
+++ b/fanficfare/cli.py
@@ -432,7 +432,7 @@ def do_download(arg,
# three tries, that's enough if both user/pass & is_adult needed,
# or a couple tries of one or the other
- for x in range(0, 2):
+ for x in range(0, 4):
try:
adapter.getStoryMetadataOnly()
except exceptions.FailedToLogin as f:
@@ -447,6 +447,14 @@ def do_download(arg,
adapter.username = sys.stdin.readline().strip()
adapter.password = getpass.getpass(prompt='Password: ')
# print('Login: `%s`, Password: `%s`' % (adapter.username, adapter.password))
+ except exceptions.NeedTimedOneTimePassword as e:
+ if not options.interactive:
+ print('Login Failed on non-interactive process. Need Timed One Time Password(TOTP) 2FA for (%s).'%e.url)
+ return
+ print('Need Timed One Time Password(TOTP) 2FA for (%s):'%e.url)
+ sys.stdout.write('\nEnter TOTP: ')
+ adapter.totp = sys.stdin.readline().strip()
+ # print('Login: `%s`, Password: `%s`' % (adapter.username, adapter.password))
except exceptions.AdultCheckRequired:
if options.interactive:
print('Please confirm you are an adult in your locale: (y/n)?')
diff --git a/fanficfare/exceptions.py b/fanficfare/exceptions.py
index c3363861..edc01a79 100644
--- a/fanficfare/exceptions.py
+++ b/fanficfare/exceptions.py
@@ -61,6 +61,13 @@ class FailedToLogin(Exception):
else:
return "Failed to Login for URL: (%s) with username: (%s)" % (self.url, self.username)
+class NeedTimedOneTimePassword(Exception):
+ def __init__(self,url):
+ self.url=url
+
+ def __str__(self):
+ return "Timed One Time Password(TOTP) required for 2 Factor Authentication(2FA): (%s) " % (self.url)
+
class AdultCheckRequired(Exception):
def __init__(self,url):
self.url=url