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