Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 110 additions & 8 deletions src/opower/utilities/eversource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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:
Expand Down
114 changes: 112 additions & 2 deletions tests/opower/utilities/test_eversource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"])
Loading