From 708e6cccefebc39553dd3ff24e4a54627d89a8b1 Mon Sep 17 00:00:00 2001 From: Dorian Roly Date: Wed, 21 Apr 2021 21:57:44 +0200 Subject: [PATCH] [bnporc/pp] Rework login as it is done on the website The login process has changed on bnp, at least the old method we used is now deprecated. The error message on login is now dynamic. --- modules/bnporc/pp/browser.py | 95 +++++++++++++++++++++----- modules/bnporc/pp/pages.py | 125 ++++++----------------------------- 2 files changed, 100 insertions(+), 120 deletions(-) diff --git a/modules/bnporc/pp/browser.py b/modules/bnporc/pp/browser.py index bf7128b00b..2bef7b52e9 100644 --- a/modules/bnporc/pp/browser.py +++ b/modules/bnporc/pp/browser.py @@ -38,14 +38,15 @@ from woob.tools.decorators import retry from woob.tools.capabilities.bank.bank_transfer import sorted_transfers from woob.tools.capabilities.bank.transactions import sorted_transactions -from woob.browser.exceptions import ServerError +from woob.browser.exceptions import ServerError, ClientError from woob.browser.elements import DataError from woob.exceptions import ( BrowserIncorrectPassword, BrowserUnavailable, AppValidation, - AppValidationExpired, ActionNeeded, + AppValidationExpired, ActionNeeded, BrowserUserBanned, BrowserPasswordExpired, ) from woob.tools.value import Value from woob.tools.capabilities.bank.investments import create_french_liquidity +from woob.browser.filters.standard import QueryValue from .pages import ( LoginPage, AccountsPage, AccountsIBANPage, HistoryPage, TransferInitPage, @@ -55,7 +56,7 @@ RecipientsPage, ValidateTransferPage, RegisterTransferPage, AdvisorPage, AddRecipPage, ActivateRecipPage, ProfilePage, ListDetailCardPage, ListErrorPage, UselessPage, TransferAssertionError, LoanDetailsPage, TransfersPage, OTPPage, - UnavailablePage, + UnavailablePage, InitLoginPage, FinalizeLoginPage, ) from .document_pages import DocumentsPage, TitulairePage, RIBPage @@ -65,14 +66,25 @@ class BNPParibasBrowser(LoginBrowser, StatesMixin): TIMEOUT = 30.0 + init_login = URL( + r'https://connexion-mabanque.bnpparibas/oidc/authorize', + InitLoginPage + ) login = URL( - r'identification-wspl-pres/identification\?acceptRedirection=true×tamp=(?P\d+)', - r'SEEA-pa01/devServer/seeaserver', - r'https://mabanqueprivee.bnpparibas.net/fr/espace-prive/comptes-et-contrats\?u=%2FSEEA-pa01%2FdevServer%2Fseeaserver', + r'https://connexion-mabanque.bnpparibas/login', LoginPage ) + finalize_login = URL( + r'SEEA-pa01/devServer/seeaserver', + FinalizeLoginPage + ) + + errors_list = URL( + r'/rsc/contrib/identification/src/zonespubliables/mabanque-part/fr/identification-fr-part-CAS.json' + ) + list_error_page = URL( r'https://mabanque.bnpparibas/rsc/contrib/document/properties/identification-fr-part-V1.json', ListErrorPage ) @@ -138,6 +150,8 @@ class BNPParibasBrowser(LoginBrowser, StatesMixin): profile = URL(r'/kyc-wspl/rest/informationsClient', ProfilePage) list_detail_card = URL(r'/udcarte-wspl/rest/listeDetailCartes', ListDetailCardPage) + DIST_ID = None + STATE_DURATION = 10 __states__ = ('rcpt_transfer_id',) @@ -158,19 +172,66 @@ def do_login(self): if not (self.username.isdigit() and self.password.isdigit()): raise BrowserIncorrectPassword() - timestamp = int(time.time() * 1e3) - # If a previous login session is still valid, we will be redirected with a - # 302 http status code. Otherwise, the page content will be returned directly. - # We have to avoid following redirects as there is a bug with bnpparibas - # website that could enter in a redirect loop if we try to go to the page - # more than once with an active session. + try: + self.init_login.go( + params={ + 'client_id': '0e0fe16f-4e44-4138-9c46-fdf077d56087', + 'scope': 'openid bnpp_mabanque ikpi', + 'response_type': 'code', + 'redirect_uri': 'https://mabanque.bnpparibas/fr/connexion', + 'ui': 'classic part', + 'ui_locales': 'fr', + 'wcm_referer': 'mabanque.bnpparibas/', + } + ) + self.page.login(self.username, self.password) + except ClientError as e: + # We have to call the page manually with the response + # in order to get the error message + message = LoginPage(self, e.response).get_error() + + # Get dynamically error messages + rep = self.errors_list.open() + + error_message = rep.json().get(message).replace('
', ' ') + + if message in ('authenticationFailure.ClientNotFoundException201', 'authenticationFailure.SecretErrorException201'): + raise BrowserIncorrectPassword(error_message) + if message in ('authenticationFailure.CurrentS1DelayException3', 'authenticationFailure.CurrentS2DelayException4'): + raise BrowserUserBanned(error_message) + raise AssertionError('Unhandled error at login: %s: %s' % (message, error_message)) + + code = QueryValue(None, 'code').filter(self.url) + + auth = ( + '%sBNPPOIDC_CAS' + + '%s0e0fe16f-4e44-4138-9c46-fdf077d56087' + + 'https://mabanque.bnpparibas/fr/connexion' + ) + self.location( - self.login.build(timestamp=timestamp), + self.BASEURL + 'SEEA-pa01/devServer/seeaserver', + data={ + 'AUTH': auth % (self.DIST_ID, code), + }, allow_redirects=False, ) - if self.login.is_here(): - self.page.login(self.username, self.password) + # We must check each request one by one to check if an otp will be sent after the redirections + for _ in range(6): + next_location = self.response.headers.get('location') + if not next_location: + break + # This is temporary while we handle the new change pass + if self.con_threshold.is_here(): + raise BrowserPasswordExpired('Vous avez atteint le seuil de 100 connexions avec le même code secret.') + self.location(next_location, allow_redirects=False) + if self.otp.is_here(): + raise ActionNeeded( + "Veuillez réaliser l'authentification forte depuis votre navigateur." + ) + else: + raise AssertionError('Multiple redirects, check if we are not in an infinite loop') def load_state(self, state): # reload state only for new recipient feature @@ -685,6 +746,9 @@ def iter_transfers(self, account): class BNPPartPro(BNPParibasBrowser): BASEURL_TEMPLATE = r'https://%s.bnpparibas/' BASEURL = BASEURL_TEMPLATE % 'mabanque' + # BNPNetEntrepros is supposed to be for pro accounts, but it seems that BNPNetParticulier + # works for pros as well, on the other side BNPNetEntrepros doesn't work for part + DIST_ID = 'BNPNetParticulier' def __init__(self, config=None, *args, **kwargs): self.config = config @@ -748,3 +812,4 @@ def iter_documents(self, subscription): class HelloBank(BNPParibasBrowser): BASEURL = 'https://www.hellobank.fr/' + DIST_ID = 'HelloBank' diff --git a/modules/bnporc/pp/pages.py b/modules/bnporc/pp/pages.py index 037a218e0b..aa1d018bf1 100644 --- a/modules/bnporc/pp/pages.py +++ b/modules/bnporc/pp/pages.py @@ -24,13 +24,9 @@ from collections import Counter import re from io import BytesIO -from random import randint from decimal import Decimal from datetime import datetime, timedelta -import lxml.html as html -from requests.exceptions import ConnectionError - from woob.browser.elements import DictElement, ListElement, TableElement, ItemElement, method from woob.browser.filters.json import Dict from woob.browser.filters.standard import ( @@ -38,7 +34,7 @@ Field, Coalesce, Map, MapIn, Env, Currency, FromTimestamp, ) from woob.browser.filters.html import TableCell -from woob.browser.pages import JsonPage, LoggedPage, HTMLPage, PartialHTMLPage +from woob.browser.pages import JsonPage, LoggedPage, HTMLPage, PartialHTMLPage, RawPage from woob.capabilities import NotAvailable from woob.capabilities.bank import ( Account, Recipient, Transfer, TransferBankError, @@ -53,8 +49,7 @@ from woob.capabilities.contact import Advisor from woob.capabilities.profile import Person, ProfileMissing from woob.exceptions import ( - BrowserIncorrectPassword, BrowserUnavailable, - BrowserPasswordExpired, ActionNeeded, + BrowserUnavailable, BrowserPasswordExpired, AppValidationCancelled, AppValidationExpired, ) from woob.tools.capabilities.bank.iban import rib2iban, rebuild_rib, is_iban_valid @@ -129,7 +124,6 @@ def on_load(self): self.doc ) ) - self.logger.warning('Password expired.') if not self.browser.rotating_password: raise BrowserPasswordExpired(msg) @@ -203,110 +197,31 @@ def get_error_message(self, error): return None -class LoginPage(JsonPage): - def is_here(self): - # If we are already logged in and we go to the page without following redirections, - # we will be redirected instead of being presented with a page content when - # everything is good and we don't have to login anymore - return self.response.status_code != 302 +class InitLoginPage(RawPage): + pass - @staticmethod - def render_template(tmpl, **values): - for k, v in values.items(): - tmpl = tmpl.replace('{{ ' + k + ' }}', v) - return tmpl - @staticmethod - def generate_token(length=11): - chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz' - return ''.join((chars[randint(0, len(chars) - 1)] for _ in range(length))) +class LoginPage(HTMLPage): + def login(self, username, password): + url = Regexp(CleanText('//style[contains(text(), "grid")]'), r"url\(\"([^\"]+)\"")(self.doc) + keyboard = self.browser.open(url) + vk = BNPKeyboard(self.browser, keyboard) - def build_doc(self, text): - try: - return super(LoginPage, self).build_doc(text) - except ValueError: - # XXX When login is successful, server sends HTML instead of JSON, - # we can ignore it. - return {} + form = self.get_form(id="logincanalnet") + form['userGridPasswordCredential.username'] = username + form['userGridPasswordCredential.gridPosition'] = vk.get_string_code(password) - def on_load(self): - if self.url.startswith('https://mabanqueprivee.'): - self.browser.switch('mabanqueprivee') - - # Some kind of internal server error instead of normal wrongpass errorCode. - if self.get('errorCode') == 'INTO_FACADE ERROR: JDF_GENERIC_EXCEPTION': - raise BrowserIncorrectPassword() - - error = cast(self.get('errorCode', self.get('codeRetour')), int, 0) - # you can find api documentation on errors here : https://mabanque.bnpparibas/rsc/contrib/document/properties/identification-fr-part-V1.json - if error: - try: - # this page can be unreachable - error_page = self.browser.list_error_page.open() - msg = error_page.get_error_message(error) or self.get('message') - except ConnectionError: - msg = self.get('message') - - wrongpass_codes = [201, 21510, 203, 202, 7] - actionNeeded_codes = [21501, 3, 4, 50] - # 'codeRetour' list - # -1 : Erreur technique lors de l'accès à l'application - # -99 : Service actuellement indisponible - websiteUnavailable_codes = [207, 1000, 1001, -99, -1] - if error in wrongpass_codes: - raise BrowserIncorrectPassword(msg) - elif error == 21: # "Ce service est momentanément indisponible. Veuillez renouveler votre demande ultérieurement." -> In reality, account is blocked because of too much wrongpass - raise ActionNeeded(u"Compte bloqué") - elif error in actionNeeded_codes: - raise ActionNeeded(msg) - elif error in websiteUnavailable_codes: - raise BrowserUnavailable(msg) - else: - raise AssertionError('Unexpected error at login: "%s" (code=%s)' % (msg, error)) + form.submit() - parser = html.HTMLParser() - doc = html.parse(BytesIO(self.content), parser) - error = CleanText('//div[h1[contains(text(), "Incident en cours")]]/p')(doc) - if error: - raise BrowserUnavailable(error) + def get_error(self): + return Regexp( + CleanText('//form[@id="logincanalnet"]//script//text()'), + r"errorMessage = \[\"([^\"]+)\"\]" + )(self.doc) - def login(self, username, password): - url = '/identification-wspl-pres/grille/%s' % self.get('data.grille.idGrille') - keyboard = self.browser.open(url) - vk = BNPKeyboard(self.browser, keyboard) - target = self.browser.BASEURL + 'SEEA-pa01/devServer/seeaserver' - user_agent = self.browser.session.headers.get('User-Agent') or '' - auth = self.render_template( - self.get('data.authTemplate'), - idTelematique=username, - password=vk.get_string_code(password), - clientele=user_agent - ) - # XXX useless? - csrf = self.generate_token() - response = self.browser.location(target, data={'AUTH': auth, 'CSRF': csrf}, allow_redirects=False) - for _ in range(5): - # We can be on infinite loop redirections, we must catch the good error - # with ConnectionThresholdPage on_load (ex:PasswordExpired on secure/100-connexions) - next_location = response.headers.get('location') - if next_location: - response = self.browser.location(next_location, allow_redirects=False) - if self.browser.otp.is_here(): - raise ActionNeeded( - "Veuillez réaliser l'authentification forte depuis votre navigateur." - ) - continue - break - else: - raise AssertionError('Multiple redirects, check if we are not in an infinite loop') - - if 'authentification-forte' in response.url: - raise ActionNeeded("Veuillez réaliser l'authentification forte depuis votre navigateur.") - if response.url.startswith('https://pro.mabanque.bnpparibas'): - self.browser.switch('pro.mabanque') - if response.url.startswith('https://banqueprivee.mabanque.bnpparibas'): - self.browser.switch('banqueprivee.mabanque') +class FinalizeLoginPage(RawPage): + pass class OTPPage(HTMLPage): -- GitLab