Sfoglia il codice sorgente

Add support for login checkpoints

Tulir Asokan 4 anni fa
parent
commit
da861c1460

+ 1 - 1
mauigpapi/errors/__init__.py

@@ -5,4 +5,4 @@ from .response import (IGResponseError, IGActionSpamError, IGNotFoundError, IGRa
                        IGCheckpointError, IGUserHasLoggedOutError, IGLoginRequiredError,
                        IGCheckpointError, IGUserHasLoggedOutError, IGLoginRequiredError,
                        IGPrivateUserError, IGSentryBlockError, IGInactiveUserError, IGLoginError,
                        IGPrivateUserError, IGSentryBlockError, IGInactiveUserError, IGLoginError,
                        IGLoginTwoFactorRequiredError, IGLoginBadPasswordError, IGBad2FACodeError,
                        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
 from ..types import SpamResponse, CheckpointResponse, LoginRequiredResponse, LoginErrorResponse
 
 
 
 
+class IGChallengeWrongCodeError(IGError):
+    pass
+
+
 class IGResponseError(IGError):
 class IGResponseError(IGError):
     response: ClientResponse
     response: ClientResponse
 
 

+ 2 - 1
mauigpapi/http/api.py

@@ -1,7 +1,8 @@
 from .thread import ThreadAPI
 from .thread import ThreadAPI
 from .login_simulate import LoginSimulateAPI
 from .login_simulate import LoginSimulateAPI
 from .upload import UploadAPI
 from .upload import UploadAPI
+from .challenge import ChallengeAPI
 
 
 
 
-class AndroidAPI(ThreadAPI, LoginSimulateAPI, UploadAPI):
+class AndroidAPI(ThreadAPI, LoginSimulateAPI, UploadAPI, ChallengeAPI):
     pass
     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())
         self._loop.create_task(self._post_connect())
 
 
     async def _post_connect(self) -> None:
     async def _post_connect(self) -> None:
+        await self._dispatch(Connect())
         self.log.debug("Re-subscribing to things after connect")
         self.log.debug("Re-subscribing to things after connect")
         if self._graphql_subs:
         if self._graphql_subs:
             res = await self.graphql_subscribe(self._graphql_subs)
             res = await self.graphql_subscribe(self._graphql_subs)
@@ -366,7 +367,6 @@ class AndroidMQTT:
 
 
         self.log.debug("Connecting to Instagram MQTT")
         self.log.debug("Connecting to Instagram MQTT")
         await self._reconnect()
         await self._reconnect()
-        await self._dispatch(Connect())
         exit_if_not_connected = False
         exit_if_not_connected = False
 
 
         while True:
         while True:
@@ -405,7 +405,6 @@ class AndroidMQTT:
 
 
                 await self._reconnect()
                 await self._reconnect()
                 exit_if_not_connected = True
                 exit_if_not_connected = True
-                await self._dispatch(Connect())
             else:
             else:
                 exit_if_not_connected = False
                 exit_if_not_connected = False
         if self._disconnect_error:
         if self._disconnect_error:

+ 2 - 1
mauigpapi/state/state.py

@@ -24,6 +24,7 @@ import attr
 from mautrix.types import SerializableAttrs
 from mautrix.types import SerializableAttrs
 
 
 from ..errors import IGNoCheckpointError, IGCookieNotFoundError, IGUserIDNotFoundError
 from ..errors import IGNoCheckpointError, IGCookieNotFoundError, IGUserIDNotFoundError
+from ..types import ChallengeStateResponse
 from .device import AndroidDevice
 from .device import AndroidDevice
 from .session import AndroidSession
 from .session import AndroidSession
 from .application import AndroidApplication
 from .application import AndroidApplication
@@ -39,7 +40,7 @@ class AndroidState(SerializableAttrs['AndroidState']):
     experiments: AndroidExperiments = attr.ib(factory=lambda: AndroidExperiments())
     experiments: AndroidExperiments = attr.ib(factory=lambda: AndroidExperiments())
     client_session_id_lifetime: int = 1_200_000
     client_session_id_lifetime: int = 1_200_000
     pigeon_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"})
     _challenge_path: Optional[str] = attr.ib(default=None, metadata={"json": "challenge_path"})
     cookies: Cookies = attr.ib(factory=lambda: Cookies())
     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,
                    ClientConfigUpdatePayload, ClientConfigUpdateEvent, RealtimeDirectData,
                    RealtimeDirectEvent, LiveVideoSystemComment, LiveVideoCommentEvent,
                    RealtimeDirectEvent, LiveVideoSystemComment, LiveVideoCommentEvent,
                    LiveVideoComment, LiveVideoCommentPayload)
                    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.state import AndroidState
 from mauigpapi.http import AndroidAPI
 from mauigpapi.http import AndroidAPI
 from mauigpapi.errors import (IGLoginTwoFactorRequiredError, IGLoginBadPasswordError,
 from mauigpapi.errors import (IGLoginTwoFactorRequiredError, IGLoginBadPasswordError,
-                              IGLoginInvalidUserError, IGBad2FACodeError)
+                              IGLoginInvalidUserError, IGBad2FACodeError, IGCheckpointError,
+                              IGChallengeWrongCodeError)
 from mauigpapi.types import BaseResponseUser
 from mauigpapi.types import BaseResponseUser
 
 
 from .typehint import CommandEvent
 from .typehint import CommandEvent
@@ -80,6 +81,14 @@ async def login(evt: CommandEvent) -> None:
             "2fa_identifier": tfa_info.two_factor_identifier,
             "2fa_identifier": tfa_info.two_factor_identifier,
         }
         }
         await evt.reply(msg)
         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:
     except IGLoginInvalidUserError:
         await evt.reply("Invalid username")
         await evt.reply("Invalid username")
     except IGLoginBadPasswordError:
     except IGLoginBadPasswordError:
@@ -100,6 +109,14 @@ async def enter_login_2fa(evt: CommandEvent) -> None:
     except IGBad2FACodeError:
     except IGBad2FACodeError:
         await evt.reply("Invalid 2-factor authentication code. Please try again "
         await evt.reply("Invalid 2-factor authentication code. Please try again "
                         "or use `$cmdprefix+sp cancel` to cancel.")
                         "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:
     except Exception as e:
         await evt.reply(f"Failed to log in: {e}")
         await evt.reply(f"Failed to log in: {e}")
         evt.log.exception("Failed to log in")
         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)
         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,
 async def _post_login(evt: CommandEvent, api: AndroidAPI, state: AndroidState,
                       user: BaseResponseUser) -> None:
                       user: BaseResponseUser) -> None:
     await api.simulate_post_login_flow()
     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())
         self.loop.create_task(self._try_sync())
 
 
     async def on_connect(self, evt: Connect) -> None:
     async def on_connect(self, evt: Connect) -> None:
+        self.log.debug("Connected to Instagram")
         self._track_metric(METRIC_CONNECTED, True)
         self._track_metric(METRIC_CONNECTED, True)
 
 
     async def on_disconnect(self, evt: Disconnect) -> None:
     async def on_disconnect(self, evt: Disconnect) -> None:
+        self.log.debug("Disconnected from Instagram")
         self._track_metric(METRIC_CONNECTED, False)
         self._track_metric(METRIC_CONNECTED, False)
 
 
     # TODO this stuff could probably be moved to mautrix-python
     # 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
 # 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/>.
 # 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 logging
 import asyncio
 import asyncio
 import json
 import json
@@ -23,8 +23,9 @@ from aiohttp import web
 from mauigpapi import AndroidState, AndroidAPI
 from mauigpapi import AndroidState, AndroidAPI
 from mauigpapi.types import BaseResponseUser
 from mauigpapi.types import BaseResponseUser
 from mauigpapi.errors import (IGLoginTwoFactorRequiredError, IGLoginBadPasswordError,
 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 mautrix.util.logging import TraceLogger
 
 
 from ..commands.auth import get_login_state
 from ..commands.auth import get_login_state
@@ -104,12 +105,7 @@ class ProvisioningAPI:
         return web.json_response(data, headers=self._acao_headers)
         return web.json_response(data, headers=self._acao_headers)
 
 
     async def login(self, request: web.Request) -> web.Response:
     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:
         try:
             username = data["username"]
             username = data["username"]
@@ -124,18 +120,25 @@ class ProvisioningAPI:
             return web.json_response(data={
             return web.json_response(data={
                 "status": "two-factor",
                 "status": "two-factor",
                 "response": e.body.serialize(),
                 "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:
         except IGLoginInvalidUserError:
             return web.json_response(data={"status": "invalid-username"},
             return web.json_response(data={"status": "invalid-username"},
-                                     status=404, headers=self._headers)
+                                     status=404, headers=self._acao_headers)
         except IGLoginBadPasswordError:
         except IGLoginBadPasswordError:
             return web.json_response(data={"status": "incorrect-password"},
             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)
         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)
         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}',
             raise web.HTTPNotFound(text='{"error": "No 2-factor login in progress}',
                                    headers=self._headers)
                                    headers=self._headers)
 
 
@@ -143,6 +146,10 @@ class ProvisioningAPI:
             data = await request.json()
             data = await request.json()
         except json.JSONDecodeError:
         except json.JSONDecodeError:
             raise web.HTTPBadRequest(text='{"error": "Malformed JSON"}', headers=self._headers)
             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:
         try:
             username = data["username"]
             username = data["username"]
@@ -157,14 +164,39 @@ class ProvisioningAPI:
         try:
         try:
             resp = await api.two_factor_login(username, code=code, identifier=identifier,
             resp = await api.two_factor_login(username, code=code, identifier=identifier,
                                               is_totp=is_totp)
                                               is_totp=is_totp)
-        except IGBad2FACodeError as e:
+        except IGBad2FACodeError:
             return web.json_response(data={
             return web.json_response(data={
                 "status": "incorrect-2fa-code",
                 "status": "incorrect-2fa-code",
             }, status=403, headers=self._acao_headers)
             }, 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)
         return await self._finish_login(user, api, state, resp.logged_in_user)
 
 
     async def _finish_login(self, user: 'u.User', api: AndroidAPI, state: AndroidState,
     async def _finish_login(self, user: 'u.User', api: AndroidAPI, state: AndroidState,
                             resp_user: BaseResponseUser) -> web.Response:
                             resp_user: BaseResponseUser) -> web.Response:
+        await api.simulate_post_login_flow()
         user.state = state
         user.state = state
         pl = state.device.payload
         pl = state.device.payload
         manufacturer, model = pl["manufacturer"], pl["model"]
         manufacturer, model = pl["manufacturer"], pl["model"]