Source code for woob.browser.mfa
# Copyright(C) 2022 woob project
#
# This file is part of woob.
#
# woob is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# woob is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with woob. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Dict, Callable, Tuple, Any, ClassVar
from datetime import timedelta
from dateutil import parser, tz
from woob.exceptions import (
NeedInteractiveFor2FA, BrowserInteraction,
)
from woob.tools.date import now_as_utc
from woob.tools.value import Value
from .browsers import LoginBrowser, StatesMixin
__all__ = ["TwoFactorBrowser"]
[docs]class TwoFactorBrowser(LoginBrowser, StatesMixin):
"""
Browser which inherits from :class:`LoginBrowser` to implement 2FA authentication.
:param config: configuration of the backend
:type config: :class:`BackendConfig`
"""
TWOFA_DURATION: ClassVar[int | float | None] = None
"""
Period to keep the same state
It is different from STATE_DURATION which updates the expire date at each
dump.
"""
INTERACTIVE_NAME: ClassVar[str] = 'request_information'
"""
Config's key which is set to a non-empty value when we are in interactive mode.
"""
AUTHENTICATION_METHODS: ClassVar[Dict[str, Callable]] = {}
"""
Dict of config keys and methods used for double authentication.
Must be set up in the init to handle function pointers.
"""
COOKIES_TO_CLEAR: ClassVar[Tuple[str, ...]] = ()
"""List of cookie keys to clear before dumping state"""
HAS_CREDENTIALS_ONLY: ClassVar[bool] = False
"""Login can also be done with credentials without 2FA"""
SKIP_LOCATE_BROWSER_ON_CONFIG_VALUES: ClassVar[Tuple[str, ...]] = ()
"""
Skip locate_browser if one of the config values is defined (for example
its useful to prevent calling twice the url that sends an OTP)
"""
def __init__(self, config, *args, **kwargs):
super().__init__(*args, **kwargs)
self.config = config
self.is_interactive = config.get(self.INTERACTIVE_NAME, Value()).get() is not None
self.twofa_logged_date = None
[docs] def get_expire(self) -> str | None:
if self.STATE_DURATION is None:
return None
expires_dates = [now_as_utc() + timedelta(minutes=self.STATE_DURATION)]
if self.twofa_logged_date and self.TWOFA_DURATION is not None:
expires_dates.append(self.twofa_logged_date + timedelta(minutes=self.TWOFA_DURATION))
return str(max(expires_dates).replace(microsecond=0))
[docs] def dump_state(self) -> Dict[str, Any]:
self.clear_not_2fa_cookies()
state = super().dump_state()
if self.twofa_logged_date:
state['twofa_logged_date'] = str(self.twofa_logged_date)
return state
[docs] def should_skip_locate_browser(self) -> bool:
for key in self.SKIP_LOCATE_BROWSER_ON_CONFIG_VALUES:
value = self.config.get(key)
if value is None:
continue
if value.get() != value.default:
return True
return False
[docs] def locate_browser(self, state: Dict[str, Any]):
if self.should_skip_locate_browser():
return
super().locate_browser(state)
[docs] def load_state(self, state: Dict[str, Any]):
super().load_state(state)
self.twofa_logged_date = None
if state.get('twofa_logged_date') not in (None, '', 'None'):
twofa_logged_date = parser.parse(state['twofa_logged_date'])
if not twofa_logged_date.tzinfo:
twofa_logged_date = twofa_logged_date.replace(tzinfo=tz.tzlocal())
self.twofa_logged_date = twofa_logged_date
[docs] def init_login(self):
"""
Abstract method to implement initiation of login on website.
This method should raise an exception.
SCA exceptions :
- AppValidation for polling method
- BrowserQuestion for SMS method, token method etc.
Any other exceptions, default to BrowserIncorrectPassword.
"""
raise NotImplementedError()
[docs] def clear_init_cookies(self):
# clear cookies to avoid some errors
self.session.cookies.clear()
[docs] def clear_not_2fa_cookies(self):
# clear cookies that we don't need for 2FA
for cookie_key in self.COOKIES_TO_CLEAR:
if cookie_key in self.session.cookies:
del self.session.cookies[cookie_key]
[docs] def check_interactive(self):
if not self.is_interactive:
raise NeedInteractiveFor2FA()
[docs] def do_double_authentication(self):
"""
This method will check AUTHENTICATION_METHODS
to dispatch to the right handle_* method.
If no backend configuration could be found,
it will then call init_login method.
"""
def clear_sca_key(config_key):
value = self.config.get(config_key)
if value is not None:
value.set(value.default)
assert self.AUTHENTICATION_METHODS, 'There is no config for the double authentication.'
for config_key, handle_method in self.AUTHENTICATION_METHODS.items():
config_value = self.config.get(config_key, Value())
if not config_value:
continue
setattr(self, config_key, config_value.get())
if getattr(self, config_key):
try:
handle_method()
except BrowserInteraction:
# If a BrowserInteraction is raised during the handling of the sca_key,
# we need to clear it before restarting the process to prevent it to block
# other sca_keys handling.
clear_sca_key(config_key)
raise
self.twofa_logged_date = now_as_utc()
# cleaning authentication config keys
for config_key in self.AUTHENTICATION_METHODS.keys():
clear_sca_key(config_key)
break
else:
if not self.HAS_CREDENTIALS_ONLY:
self.check_interactive()
self.clear_init_cookies()
self.init_login()
do_login = do_double_authentication