Selaa lähdekoodia

Add support for login checkpoints

Tulir Asokan 4 vuotta sitten
vanhempi
sitoutus
da861c1460

+ 1 - 1
mauigpapi/errors/__init__.py

@@ -5,4 +5,4 @@ from .response import (IGResponseError, IGActionSpamError, IGNotFoundError, IGRa
                        IGCheckpointError, IGUserHasLoggedOutError, IGLoginRequiredError,
                        IGPrivateUserError, IGSentryBlockError, IGInactiveUserError, IGLoginError,
                        IGLoginTwoFactorRequiredError, IGLoginBadPasswordError, IGBad2FACodeError,
-                       IGLoginInvalidUserError, IGNotLoggedInError)
+                       IGLoginInvalidUserError, IGNotLoggedInError, IGChallengeWrongCodeError)

+ 4 - 0
mauigpapi/errors/response.py

@@ -22,6 +22,10 @@ from .base import IGError
 from ..types import SpamResponse, CheckpointResponse, LoginRequiredResponse, LoginErrorResponse
 
 
+class IGChallengeWrongCodeError(IGError):
+    pass
+
+
 class IGResponseError(IGError):
     response: ClientResponse
 

+ 2 - 1
mauigpapi/http/api.py

@@ -1,7 +1,8 @@
 from .thread import ThreadAPI
 from .login_simulate import LoginSimulateAPI
 from .upload import UploadAPI
+from .challenge import ChallengeAPI
 
 
-class AndroidAPI(ThreadAPI, LoginSimulateAPI, UploadAPI):
+class AndroidAPI(ThreadAPI, LoginSimulateAPI, UploadAPI, ChallengeAPI):
     pass

+ 104 - 0
mauigpapi/http/challenge.py

@@ -0,0 +1,104 @@
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2020 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+from typing import Union
+
+from .base import BaseAndroidAPI
+from ..types import ChallengeStateResponse
+from ..errors import IGResponseError, IGChallengeWrongCodeError
+
+
+class ChallengeAPI(BaseAndroidAPI):
+    @property
+    def __path(self) -> str:
+        return f"/api/v1/{self.state.challenge_path}"
+
+    async def challenge_get_state(self) -> ChallengeStateResponse:
+        query = {
+            "guid": self.state.device.uuid,
+            "device_id": self.state.device.id,
+        }
+        return self.__handle_resp(await self.std_http_get(self.__path, query=query,
+                                                          response_type=ChallengeStateResponse))
+
+    async def challenge_select_method(self, choice: str, is_replay: bool = False
+                                      ) -> ChallengeStateResponse:
+        path = self.__path
+        if is_replay:
+            path = path.replace("/challenge/", "/challenge/replay/")
+        req = {
+            "choice": choice,
+            "_csrftoken": self.state.cookies.csrf_token,
+            "guid": self.state.device.uuid,
+            "device_id": self.state.device.id,
+        }
+        return self.__handle_resp(await self.std_http_post(path, data=req,
+                                                           response_type=ChallengeStateResponse))
+
+    async def challenge_delta_review(self, was_me: bool = True) -> ChallengeStateResponse:
+        return await self.challenge_select_method("0" if was_me else "1")
+
+    async def challenge_send_phone_number(self, phone_number: str) -> ChallengeStateResponse:
+        req = {
+            "phone_number": phone_number,
+            "_csrftoken": self.state.cookies.csrf_token,
+            "guid": self.state.device.uuid,
+            "device_id": self.state.device.id,
+        }
+        return self.__handle_resp(await self.std_http_post(self.__path, data=req,
+                                                           response_type=ChallengeStateResponse))
+
+    async def challenge_send_security_code(self, code: Union[str, int]) -> ChallengeStateResponse:
+        req = {
+            "security_code": code,
+            "_csrftoken": self.state.cookies.csrf_token,
+            "guid": self.state.device.uuid,
+            "device_id": self.state.device.id,
+        }
+        try:
+            return self.__handle_resp(await self.std_http_post(
+                self.__path, data=req, response_type=ChallengeStateResponse))
+        except IGResponseError as e:
+            if e.response.status == 400:
+                raise IGChallengeWrongCodeError((await e.response.json())["message"]) from e
+            raise
+
+    async def challenge_reset(self) -> ChallengeStateResponse:
+        req = {
+            "_csrftoken": self.state.cookies.csrf_token,
+            "guid": self.state.device.uuid,
+            "device_id": self.state.device.id,
+        }
+        return self.__handle_resp(await self.std_http_post(
+            self.__path.replace("/challenge/", "/challenge/reset/"),
+            data=req, response_type=ChallengeStateResponse))
+
+    async def challenge_auto(self, reset: bool = False) -> ChallengeStateResponse:
+        if reset:
+            await self.challenge_reset()
+        challenge = self.state.challenge or await self.challenge_get_state()
+        if challenge.step_name == "select_verify_method":
+            return await self.challenge_select_method(challenge.step_data.choice)
+        elif challenge.step_name == "delta_login_review":
+            return await self.challenge_delta_review(was_me=True)
+        return challenge
+
+    def __handle_resp(self, resp: ChallengeStateResponse) -> ChallengeStateResponse:
+        if resp.action == "close":
+            self.state.challenge = None
+            self.state.challenge_path = None
+        else:
+            self.state.challenge = resp
+        return resp

+ 1 - 2
mauigpapi/mqtt/conn.py

@@ -190,6 +190,7 @@ class AndroidMQTT:
         self._loop.create_task(self._post_connect())
 
     async def _post_connect(self) -> None:
+        await self._dispatch(Connect())
         self.log.debug("Re-subscribing to things after connect")
         if self._graphql_subs:
             res = await self.graphql_subscribe(self._graphql_subs)
@@ -366,7 +367,6 @@ class AndroidMQTT:
 
         self.log.debug("Connecting to Instagram MQTT")
         await self._reconnect()
-        await self._dispatch(Connect())
         exit_if_not_connected = False
 
         while True:
@@ -405,7 +405,6 @@ class AndroidMQTT:
 
                 await self._reconnect()
                 exit_if_not_connected = True
-                await self._dispatch(Connect())
             else:
                 exit_if_not_connected = False
         if self._disconnect_error:

+ 2 - 1
mauigpapi/state/state.py

@@ -24,6 +24,7 @@ import attr
 from mautrix.types import SerializableAttrs
 
 from ..errors import IGNoCheckpointError, IGCookieNotFoundError, IGUserIDNotFoundError
+from ..types import ChallengeStateResponse
 from .device import AndroidDevice
 from .session import AndroidSession
 from .application import AndroidApplication
@@ -39,7 +40,7 @@ class AndroidState(SerializableAttrs['AndroidState']):
     experiments: AndroidExperiments = attr.ib(factory=lambda: AndroidExperiments())
     client_session_id_lifetime: int = 1_200_000
     pigeon_session_id_lifetime: int = 1_200_000
-    challenge: 'Optional[ChallengeStateResponse]' = None
+    challenge: Optional[ChallengeStateResponse] = None
     _challenge_path: Optional[str] = attr.ib(default=None, metadata={"json": "challenge_path"})
     cookies: Cookies = attr.ib(factory=lambda: Cookies())
 

+ 1 - 0
mauigpapi/types/__init__.py

@@ -24,3 +24,4 @@ from .mqtt import (Operation, ThreadAction, ReactionStatus, TypingStatus, Comman
                    ClientConfigUpdatePayload, ClientConfigUpdateEvent, RealtimeDirectData,
                    RealtimeDirectEvent, LiveVideoSystemComment, LiveVideoCommentEvent,
                    LiveVideoComment, LiveVideoCommentPayload)
+from .challenge import ChallengeStateResponse, ChallengeStateData

+ 47 - 0
mauigpapi/types/challenge.py

@@ -0,0 +1,47 @@
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2020 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+from typing import Optional
+from attr import dataclass
+
+from mautrix.types import SerializableAttrs
+
+from .login import LoginResponseUser
+
+
+@dataclass
+class ChallengeStateData(SerializableAttrs['ChallengeStateData']):
+    choice: str
+    fb_access_token: str
+    big_blue_token: str
+    google_oauth_token: str
+    email: str
+    security_code: str
+    resend_delay: int
+    contact_point: str
+    form_type: str
+
+
+@dataclass(kw_only=True)
+class ChallengeStateResponse(SerializableAttrs['ChallengeStateResponse']):
+    # TODO enum?
+    step_name: str
+    step_data: ChallengeStateData
+    logged_in_user: Optional[LoginResponseUser] = None
+    user_id: int
+    nonce_code: str
+    # TODO enum?
+    action: str
+    status: str

+ 39 - 1
mautrix_instagram/commands/auth.py

@@ -19,7 +19,8 @@ from mautrix.bridge.commands import HelpSection, command_handler
 from mauigpapi.state import AndroidState
 from mauigpapi.http import AndroidAPI
 from mauigpapi.errors import (IGLoginTwoFactorRequiredError, IGLoginBadPasswordError,
-                              IGLoginInvalidUserError, IGBad2FACodeError)
+                              IGLoginInvalidUserError, IGBad2FACodeError, IGCheckpointError,
+                              IGChallengeWrongCodeError)
 from mauigpapi.types import BaseResponseUser
 
 from .typehint import CommandEvent
@@ -80,6 +81,14 @@ async def login(evt: CommandEvent) -> None:
             "2fa_identifier": tfa_info.two_factor_identifier,
         }
         await evt.reply(msg)
+    except IGCheckpointError:
+        await api.challenge_auto(reset=True)
+        evt.sender.command_status = {
+            **evt.sender.command_status,
+            "next": enter_login_security_code,
+        }
+        await evt.reply("Username and password accepted, but Instagram wants to verify it's really"
+                        " you. Please confirm the login and enter the security code here.")
     except IGLoginInvalidUserError:
         await evt.reply("Invalid username")
     except IGLoginBadPasswordError:
@@ -100,6 +109,14 @@ async def enter_login_2fa(evt: CommandEvent) -> None:
     except IGBad2FACodeError:
         await evt.reply("Invalid 2-factor authentication code. Please try again "
                         "or use `$cmdprefix+sp cancel` to cancel.")
+    except IGCheckpointError:
+        await api.challenge_auto(reset=True)
+        evt.sender.command_status = {
+            **evt.sender.command_status,
+            "next": enter_login_security_code,
+        }
+        await evt.reply("2-factor authentication code accepted, but Instagram wants to verify it's"
+                        " really you. Please confirm the login and enter the security code here.")
     except Exception as e:
         await evt.reply(f"Failed to log in: {e}")
         evt.log.exception("Failed to log in")
@@ -109,6 +126,27 @@ async def enter_login_2fa(evt: CommandEvent) -> None:
         await _post_login(evt, api, state, resp.logged_in_user)
 
 
+async def enter_login_security_code(evt: CommandEvent) -> None:
+    api: AndroidAPI = evt.sender.command_status["api"]
+    state: AndroidState = evt.sender.command_status["state"]
+    try:
+        resp = await api.challenge_send_security_code("".join(evt.args))
+    except IGChallengeWrongCodeError as e:
+        await evt.reply(f"Incorrect security code: {e}")
+    except Exception as e:
+        await evt.reply(f"Failed to log in: {e}")
+        evt.log.exception("Failed to log in")
+        evt.sender.command_status = None
+    else:
+        if not resp.logged_in_user:
+            evt.log.error(f"Didn't get logged_in_user in challenge response "
+                          f"after entering security code: {resp.serialize()}")
+            await evt.reply("An unknown error occurred. Please check the bridge logs.")
+            return
+        evt.sender.command_status = None
+        await _post_login(evt, api, state, resp.logged_in_user)
+
+
 async def _post_login(evt: CommandEvent, api: AndroidAPI, state: AndroidState,
                       user: BaseResponseUser) -> None:
     await api.simulate_post_login_flow()

+ 2 - 0
mautrix_instagram/user.py

@@ -128,9 +128,11 @@ class User(DBUser, BaseUser):
         self.loop.create_task(self._try_sync())
 
     async def on_connect(self, evt: Connect) -> None:
+        self.log.debug("Connected to Instagram")
         self._track_metric(METRIC_CONNECTED, True)
 
     async def on_disconnect(self, evt: Disconnect) -> None:
+        self.log.debug("Disconnected from Instagram")
         self._track_metric(METRIC_CONNECTED, False)
 
     # TODO this stuff could probably be moved to mautrix-python

+ 47 - 15
mautrix_instagram/web/provisioning_api.py

@@ -13,7 +13,7 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import Awaitable, Dict
+from typing import Awaitable, Dict, Tuple
 import logging
 import asyncio
 import json
@@ -23,8 +23,9 @@ from aiohttp import web
 from mauigpapi import AndroidState, AndroidAPI
 from mauigpapi.types import BaseResponseUser
 from mauigpapi.errors import (IGLoginTwoFactorRequiredError, IGLoginBadPasswordError,
-                              IGLoginInvalidUserError, IGBad2FACodeError, IGNotLoggedInError)
-from mautrix.types import UserID
+                              IGLoginInvalidUserError, IGBad2FACodeError, IGNotLoggedInError,
+                              IGCheckpointError, IGChallengeWrongCodeError)
+from mautrix.types import UserID, JSON
 from mautrix.util.logging import TraceLogger
 
 from ..commands.auth import get_login_state
@@ -104,12 +105,7 @@ class ProvisioningAPI:
         return web.json_response(data, headers=self._acao_headers)
 
     async def login(self, request: web.Request) -> web.Response:
-        user = await self.check_token(request)
-
-        try:
-            data = await request.json()
-        except json.JSONDecodeError:
-            raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers)
+        user, data = await self._get_user(request, check_state=False)
 
         try:
             username = data["username"]
@@ -124,18 +120,25 @@ class ProvisioningAPI:
             return web.json_response(data={
                 "status": "two-factor",
                 "response": e.body.serialize(),
-            }, status=202, headers=self._headers)
+            }, status=202, headers=self._acao_headers)
+        except IGCheckpointError as e:
+            await api.challenge_auto(reset=True)
+            return web.json_response(data={
+                "status": "checkpoint",
+                "response": e.body.serialize(),
+            }, status=202, headers=self._acao_headers)
         except IGLoginInvalidUserError:
             return web.json_response(data={"status": "invalid-username"},
-                                     status=404, headers=self._headers)
+                                     status=404, headers=self._acao_headers)
         except IGLoginBadPasswordError:
             return web.json_response(data={"status": "incorrect-password"},
-                                     status=403, headers=self._headers)
+                                     status=403, headers=self._acao_headers)
         return await self._finish_login(user, api, state, resp.logged_in_user)
 
-    async def login_2fa(self, request: web.Request) -> web.Response:
+    async def _get_user(self, request: web.Request, check_state: bool = False
+                        ) -> Tuple['u.User', JSON]:
         user = await self.check_token(request)
-        if not user.command_status or user.command_status["action"] != "Login":
+        if check_state and (not user.command_status or user.command_status["action"] != "Login"):
             raise web.HTTPNotFound(text='{"error": "No 2-factor login in progress}',
                                    headers=self._headers)
 
@@ -143,6 +146,10 @@ class ProvisioningAPI:
             data = await request.json()
         except json.JSONDecodeError:
             raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers)
+        return user, data
+
+    async def login_2fa(self, request: web.Request) -> web.Response:
+        user, data = await self._get_user(request, check_state=True)
 
         try:
             username = data["username"]
@@ -157,14 +164,39 @@ class ProvisioningAPI:
         try:
             resp = await api.two_factor_login(username, code=code, identifier=identifier,
                                               is_totp=is_totp)
-        except IGBad2FACodeError as e:
+        except IGBad2FACodeError:
             return web.json_response(data={
                 "status": "incorrect-2fa-code",
             }, status=403, headers=self._acao_headers)
+        except IGCheckpointError as e:
+            await api.challenge_auto(reset=True)
+            return web.json_response(data={
+                "status": "checkpoint",
+                "response": e.body.serialize(),
+            }, status=202, headers=self._acao_headers)
+        return await self._finish_login(user, api, state, resp.logged_in_user)
+
+    async def login_checkpoint(self, request: web.Request) -> web.Response:
+        user, data = await self._get_user(request, check_state=True)
+
+        try:
+            code = data["code"]
+        except KeyError:
+            raise web.HTTPBadRequest(text='{"error": "Missing keys"}', headers=self._headers)
+
+        api: AndroidAPI = user.command_status["api"]
+        state: AndroidState = user.command_status["state"]
+        try:
+            resp = await api.challenge_send_security_code(code=code)
+        except IGChallengeWrongCodeError:
+            return web.json_response(data={
+                "status": "incorrect-challenge-code",
+            }, status=403, headers=self._acao_headers)
         return await self._finish_login(user, api, state, resp.logged_in_user)
 
     async def _finish_login(self, user: 'u.User', api: AndroidAPI, state: AndroidState,
                             resp_user: BaseResponseUser) -> web.Response:
+        await api.simulate_post_login_flow()
         user.state = state
         pl = state.device.payload
         manufacturer, model = pl["manufacturer"], pl["model"]