From fc182e2c67cb70cd7501c2f2ea7432cc6cc06c3e Mon Sep 17 00:00:00 2001 From: djbombay Date: Wed, 4 Nov 2026 13:06:59 -0500 Subject: [PATCH] Eversource: support interactive MFA (text/email code) with remember-me MSLogin returns MFA_REQUIRED for accounts with 2-step verification, which was raised as InvalidAuth. Raise MfaChallenge with an EversourceMfaHandler instead: list the active sms/email factors, send the code via IssueMFAFactor, verify it via VerifyMFAFactor with mfaRememberMe, and return the remember-me data as login data. The next MSLogin sends it as MfaRememberMeData; a reply with ValidMfaRememberMeData skips MFA, as on eversource.com. Co-Authored-By: Claude Opus 5.5 --- src/opower/utilities/eversource.py | 118 ++++++++++++++++++++-- tests/opower/utilities/test_eversource.py | 114 ++++++++++++++++++++- 2 files changed, 222 insertions(+), 10 deletions(-) diff --git a/src/opower/utilities/eversource.py b/src/opower/utilities/eversource.py index 36794a4..acfbbcd 100644 --- a/src/opower/utilities/eversource.py +++ b/src/opower/utilities/eversource.py @@ -11,8 +11,8 @@ import aiohttp from ..const import USER_AGENT -from ..exceptions import CannotConnect, InvalidAuth -from .base import UtilityBase +from ..exceptions import CannotConnect, InvalidAuth, MfaChallenge +from .base import MfaHandlerBase, UtilityBase _LOGGER = logging.getLogger(__name__) @@ -133,6 +133,103 @@ async def _async_get_account_opower_token( return _WidgetResult.ERROR, f"connection error: {err}" +_MFA_ISSUE_URL = "https://www.eversource.com/security/api/OktaMFA/IssueMFAFactor" +_MFA_VERIFY_URL = "https://www.eversource.com/security/api/OktaMFA/VerifyMFAFactor" + + +def _mask(value: str) -> str: + """Mask a phone number or email address for display.""" + if "@" in value: + local, _, domain = value.partition("@") + return f"{local[:1]}***@{domain}" + digits = re.sub(r"\D", "", value) + return f"***-***-{digits[-4:]}" if len(digits) >= 4 else "***" + + +class EversourceMfaHandler(MfaHandlerBase): + """Handles interactive MFA (text or email code) for Eversource. + + Mirrors the eversource.com login page: IssueMFAFactor sends the code, + VerifyMFAFactor checks it. Verifying with mfaRememberMe returns + mfaRememberMeData; sending that with the next MSLogin skips MFA. + """ + + def __init__( + self, + session: aiohttp.ClientSession, + user_id: str, + factors: list[dict[str, Any]], + headers: dict[str, str], + ) -> None: + """Initialize the MFA handler.""" + self._session = session + self._user_id = user_id + self._factors = factors + self._headers = headers + self._factor_id: str | None = None + + async def async_get_mfa_options(self) -> dict[str, str]: + """Return the active text and email factors.""" + options: dict[str, str] = {} + for factor in self._factors: + if not isinstance(factor, dict) or factor.get("status") != "ACTIVE" or not factor.get("id"): + continue + factor_type = factor.get("factorType") + if factor_type == "sms": + phone = (factor.get("profile") or {}).get("phoneNumber") or factor.get("value") or "" + options[factor["id"]] = f"Text message to {_mask(str(phone))}" + elif factor_type == "email": + email = factor.get("value") or (factor.get("profile") or {}).get("email") or "" + options[factor["id"]] = f"Email to {_mask(str(email))}" + return options + + async def async_select_mfa_option(self, option_id: str) -> None: + """Send the code to the selected factor.""" + self._factor_id = option_id + try: + async with self._session.post( + _MFA_ISSUE_URL, + json={"userId": self._user_id, "factorId": option_id}, + headers=self._headers, + ) as resp: + if resp.status != 200: + raise CannotConnect(f"Sending MFA code failed: HTTP {resp.status}") + result: Any = await resp.json() + except (aiohttp.ClientError, ValueError) as err: + raise CannotConnect(f"Sending MFA code failed: {err}") from err + if not isinstance(result, dict) or result.get("factorResult") != "CHALLENGE": + raise CannotConnect("Eversource did not send an MFA code") + _LOGGER.debug("Eversource MFA code sent") + + async def async_submit_mfa_code(self, code: str) -> dict[str, Any]: + """Verify the code and return remember-me data for the next login.""" + if not self._factor_id: + raise InvalidAuth("No MFA option selected") + try: + async with self._session.post( + _MFA_VERIFY_URL, + json={ + "userId": self._user_id, + "factorId": self._factor_id, + "passCode": code.strip(), + "mfaRememberMe": True, + }, + headers=self._headers, + ) as resp: + if resp.status != 200: + raise CannotConnect(f"Verifying MFA code failed: HTTP {resp.status}") + result: Any = await resp.json() + except (aiohttp.ClientError, ValueError) as err: + raise CannotConnect(f"Verifying MFA code failed: {err}") from err + if not isinstance(result, dict) or result.get("invalidPasscode") or result.get("factorResult") != "SUCCESS": + raise InvalidAuth("Invalid MFA code") + remember = result.get("mfaRememberMeData") + if not remember: + raise InvalidAuth("Eversource accepted the code but returned no remember-me data") + _LOGGER.debug("Eversource MFA code accepted") + return {"mfa_remember_me_data": remember} + + class Eversource(UtilityBase): """Eversource Energy. @@ -211,7 +308,7 @@ async def async_login( # noqa: C901, PLR0912, PLR0915 "Password": password, "RememberID": False, "ReturnUrl": "/cg/customer/accountoverview", - "MfaRememberMeData": "", + "MfaRememberMeData": login_data.get("mfa_remember_me_data", ""), } headers = { @@ -244,12 +341,17 @@ async def async_login( # noqa: C901, PLR0912, PLR0915 status = json_response.get("status") okta_username = json_response.get("OktaUsername") - if status in ("SUCCESS", "MFA_ENROLL"): + if status in ("SUCCESS", "MFA_ENROLL") or json_response.get("ValidMfaRememberMeData"): + # Valid remember-me data skips MFA, as on eversource.com. _LOGGER.debug("Login successful, OktaUsername: %s", okta_username) - elif status == "MFA_REQUIRED": - raise InvalidAuth("MFA required - not yet supported") - elif status == "MFA_CHALLENGE": - raise InvalidAuth("MFA challenge required - not yet supported") + elif status in ("MFA_REQUIRED", "MFA_CHALLENGE"): + user_id = json_response.get("token") + if not user_id: + raise InvalidAuth("MFA required but Eversource returned no user id") + raise MfaChallenge( + "Eversource MFA required", + EversourceMfaHandler(session, user_id, json_response.get("factors") or [], headers), + ) else: errors = json_response.get("Errors", {}) if errors: diff --git a/tests/opower/utilities/test_eversource.py b/tests/opower/utilities/test_eversource.py index 5a09bfd..0f1bddd 100644 --- a/tests/opower/utilities/test_eversource.py +++ b/tests/opower/utilities/test_eversource.py @@ -3,8 +3,8 @@ import unittest from typing import Any -from opower.exceptions import CannotConnect, InvalidAuth -from opower.utilities.eversource import Eversource +from opower.exceptions import CannotConnect, InvalidAuth, MfaChallenge +from opower.utilities.eversource import Eversource, EversourceMfaHandler from tests.mock_http import MockResponse, MockSession LOGIN_PAGE = "https://www.eversource.com/security/account/Login" @@ -176,3 +176,113 @@ async def test_account_list_is_capped_with_a_warning(self) -> None: self.assertEqual(len(_widget_urls(session)), 50) self.assertIn("after 50", logs.output[0]) + + +MFA_ISSUE = "https://www.eversource.com/security/api/OktaMFA/IssueMFAFactor" +MFA_VERIFY = "https://www.eversource.com/security/api/OktaMFA/VerifyMFAFactor" +FACTORS = [ + {"id": "sms1", "factorType": "sms", "status": "ACTIVE", "profile": {"phoneNumber": "+1 603-555-1234"}}, + {"id": "em1", "factorType": "email", "status": "ACTIVE", "value": "someone@example.com"}, + {"id": "old", "factorType": "sms", "status": "PENDING_ACTIVATION"}, +] + + +def _mfa_required() -> MockResponse: + return MockResponse( + payload={ + "IsSuccess": True, + "status": "MFA_REQUIRED", + "OktaUsername": "user@example.com", + "token": "enc-user-id", + "factors": FACTORS, + } + ) + + +class TestEversourceMfa(unittest.IsolatedAsyncioTestCase): + """Test interactive MFA (text/email code) for Eversource.""" + + async def _challenge(self, session: MockSession) -> EversourceMfaHandler: + with self.assertRaises(MfaChallenge) as context: + await Eversource().async_login(session, "user", "pw", {}) # type: ignore[arg-type] + handler = context.exception.handler + assert isinstance(handler, EversourceMfaHandler) + return handler + + async def test_mfa_required_raises_challenge_with_active_options(self) -> None: + """MFA_REQUIRED raises MfaChallenge listing only active factors, masked.""" + responses = _login_responses({"a": {"jwtToken": "t"}}) + responses[MSLOGIN] = _mfa_required() + handler = await self._challenge(MockSession(responses)) + self.assertEqual( + {"sms1": "Text message to ***-***-1234", "em1": "Email to s***@example.com"}, + await handler.async_get_mfa_options(), + ) + + async def test_mfa_required_without_user_id_raises_invalid_auth(self) -> None: + """No encrypted user id means the MFA calls cannot be made.""" + responses = _login_responses({}) + responses[MSLOGIN] = MockResponse(payload={"IsSuccess": True, "status": "MFA_REQUIRED", "OktaUsername": "u"}) + with self.assertRaises(InvalidAuth): + await Eversource().async_login(MockSession(responses), "user", "pw", {}) # type: ignore[arg-type] + + async def test_select_sends_code_and_submit_returns_remember_me(self) -> None: + """Selecting issues the code; a correct code returns remember-me login data.""" + responses = _login_responses({}) + responses[MSLOGIN] = _mfa_required() + responses[MFA_ISSUE] = MockResponse(payload={"factorResult": "CHALLENGE"}) + responses[MFA_VERIFY] = MockResponse(payload={"factorResult": "SUCCESS", "mfaRememberMeData": "remember-abc"}) + session = MockSession(responses) + handler = await self._challenge(session) + await handler.async_select_mfa_option("sms1") + login_data = await handler.async_submit_mfa_code(" 123456 ") + self.assertEqual({"mfa_remember_me_data": "remember-abc"}, login_data) + issue = next(r for r in session.requests if r["url"] == MFA_ISSUE) + verify = next(r for r in session.requests if r["url"] == MFA_VERIFY) + self.assertEqual({"userId": "enc-user-id", "factorId": "sms1"}, issue["json"]) + self.assertEqual( + {"userId": "enc-user-id", "factorId": "sms1", "passCode": "123456", "mfaRememberMe": True}, verify["json"] + ) + + async def test_wrong_code_raises_invalid_auth(self) -> None: + """An invalid passcode raises InvalidAuth.""" + responses = _login_responses({}) + responses[MSLOGIN] = _mfa_required() + responses[MFA_ISSUE] = MockResponse(payload={"factorResult": "CHALLENGE"}) + responses[MFA_VERIFY] = MockResponse(payload={"invalidPasscode": True}) + handler = await self._challenge(MockSession(responses)) + await handler.async_select_mfa_option("em1") + with self.assertRaises(InvalidAuth): + await handler.async_submit_mfa_code("000000") + + async def test_code_not_sent_raises_cannot_connect(self) -> None: + """A non-CHALLENGE reply to the send request raises CannotConnect.""" + responses = _login_responses({}) + responses[MSLOGIN] = _mfa_required() + responses[MFA_ISSUE] = MockResponse(payload={"factorResult": "FAILED"}) + handler = await self._challenge(MockSession(responses)) + with self.assertRaises(CannotConnect): + await handler.async_select_mfa_option("sms1") + + async def test_valid_remember_me_skips_mfa(self) -> None: + """Saved remember-me data is sent and a valid one completes login without MFA.""" + responses = _login_responses({"a": {"jwtToken": "opower-token"}}) + responses[MSLOGIN] = MockResponse( + payload={ + "IsSuccess": True, + "status": "MFA_REQUIRED", + "OktaUsername": "user@example.com", + "token": "enc-user-id", + "ValidMfaRememberMeData": True, + } + ) + session = MockSession(responses) + token = await Eversource().async_login( + session, # type: ignore[arg-type] + "user", + "pw", + {"mfa_remember_me_data": "remember-abc"}, + ) + self.assertEqual("opower-token", token) + mslogin = next(r for r in session.requests if r["url"] == MSLOGIN) + self.assertEqual("remember-abc", mslogin["json"]["MfaRememberMeData"])