The new woob repository is here: https://gitlab.com/woob/woob. This gitlab will be removed soon.

Commit 708e6ccc authored by ROLY Dorian's avatar ROLY Dorian Committed by Vincent A

[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.
parent 506f6fb5
......@@ -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&timestamp=(?P<timestamp>\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('<br>', ' ')
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 = (
'<DIST_ID>%s</DIST_ID><MEAN_ID>BNPP</MEAN_ID><EAI_AUTH_TYPE>OIDC_CAS</EAI_AUTH_TYPE><OIDC>'
+ '<OIDC_CODE>%s</OIDC_CODE><OIDC_CLIENTID>0e0fe16f-4e44-4138-9c46-fdf077d56087</OIDC_CLIENTID>'
+ '<OIDC_REDIRECT_URI>https://mabanque.bnpparibas/fr/connexion</OIDC_REDIRECT_URI></OIDC>'
)
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'
......@@ -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):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment