Tulir Asokan 4 лет назад
Родитель
Сommit
3f518d325f

+ 5 - 0
mautrix_instagram/__main__.py

@@ -25,6 +25,7 @@ from .portal import Portal
 from .puppet import Puppet
 from .matrix import MatrixHandler
 from .version import version, linkified_version
+from .web import ProvisioningAPI
 from . import commands
 
 
@@ -44,6 +45,7 @@ class InstagramBridge(Bridge):
     config: Config
     matrix: MatrixHandler
     state_store: PgBridgeStateStore
+    provisioning_api: ProvisioningAPI
 
     def make_state_store(self) -> None:
         self.state_store = PgBridgeStateStore(self.db, self.get_puppet, self.get_double_puppet)
@@ -55,6 +57,9 @@ class InstagramBridge(Bridge):
 
     def prepare_bridge(self) -> None:
         super().prepare_bridge()
+        cfg = self.config["bridge.provisioning"]
+        self.provisioning_api = ProvisioningAPI(cfg["shared_secret"])
+        self.az.app.add_subapp(cfg["prefix"], self.provisioning_api.app)
 
     async def start(self) -> None:
         await self.db.start()

+ 23 - 15
mautrix_instagram/commands/auth.py

@@ -13,6 +13,8 @@
 #
 # 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 Tuple, TYPE_CHECKING
+
 from mautrix.bridge.commands import HelpSection, command_handler
 from mauigpapi.state import AndroidState
 from mauigpapi.http import AndroidAPI
@@ -22,9 +24,29 @@ from mauigpapi.types import BaseResponseUser
 
 from .typehint import CommandEvent
 
+if TYPE_CHECKING:
+    from ..user import User
+
 SECTION_AUTH = HelpSection("Authentication", 10, "")
 
 
+async def get_login_state(user: 'User', username: str) -> Tuple[AndroidAPI, AndroidState]:
+    if user.command_status and user.command_status["action"] == "Login":
+        api: AndroidAPI = user.command_status["api"]
+        state: AndroidState = user.command_status["state"]
+    else:
+        state = AndroidState()
+        state.device.generate(username)
+        api = AndroidAPI(state)
+        await api.simulate_pre_login_flow()
+        user.command_status = {
+            "action": "Login",
+            "state": state,
+            "api": api,
+        }
+    return api, state
+
+
 @command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH,
                  help_text="Log in to Instagram", help_args="<_username_> <_password_>")
 async def login(evt: CommandEvent) -> None:
@@ -36,21 +58,7 @@ async def login(evt: CommandEvent) -> None:
         return
     username = evt.args[0]
     password = " ".join(evt.args[1:])
-    if evt.sender.command_status and evt.sender.command_status["action"] == "Login":
-        api: AndroidAPI = evt.sender.command_status["api"]
-        state: AndroidState = evt.sender.command_status["state"]
-    else:
-        evt.log.trace(f"Generating new device for {username}")
-        state = AndroidState()
-        state.device.generate(username)
-        api = AndroidAPI(state)
-        await api.simulate_pre_login_flow()
-        evt.sender.command_status = {
-            "action": "Login",
-            "room_id": evt.room_id,
-            "state": state,
-            "api": api,
-        }
+    api, state = await get_login_state(evt.sender, username)
     try:
         resp = await api.login(username, password)
     except IGLoginTwoFactorRequiredError as e:

+ 4 - 1
mautrix_instagram/commands/conn.py

@@ -36,7 +36,10 @@ async def ping(evt: CommandEvent) -> None:
         return
     try:
         user_info = await evt.sender.client.current_user()
-    except IGNotLoggedInError:
+    except IGNotLoggedInError as e:
+        # TODO maybe don't always log out?
+        evt.log.exception(f"Got error checking current user for %s, logging out. %s",
+                          evt.sender.mxid, e.body.json())
         await evt.reply("You have been logged out")
         await evt.sender.logout()
     else:

+ 6 - 6
mautrix_instagram/config.py

@@ -45,12 +45,6 @@ class Config(BaseBridgeConfig):
 
         copy("homeserver.asmux")
 
-        copy("appservice.provisioning.enabled")
-        copy("appservice.provisioning.prefix")
-        copy("appservice.provisioning.shared_secret")
-        if base["appservice.provisioning.shared_secret"] == "generate":
-            base["appservice.provisioning.shared_secret"] = self._new_token()
-
         copy("appservice.community_id")
 
         copy("metrics.enabled")
@@ -81,6 +75,12 @@ class Config(BaseBridgeConfig):
         copy("bridge.delivery_error_reports")
         copy("bridge.resend_bridge_info")
 
+        copy("bridge.provisioning.enabled")
+        copy("bridge.provisioning.prefix")
+        copy("bridge.provisioning.shared_secret")
+        if base["bridge.provisioning.shared_secret"] == "generate":
+            base["bridge.provisioning.shared_secret"] = self._new_token()
+
         copy("bridge.command_prefix")
 
         copy_dict("bridge.permissions")

+ 11 - 11
mautrix_instagram/example-config.yaml

@@ -28,17 +28,6 @@ appservice:
     # The full URI to the database. Only Postgres is currently supported.
     database: postgres://username:password@hostname/db
 
-    # Provisioning API part of the web server for automated portal creation and fetching information.
-    # Used by things like mautrix-manager (https://github.com/tulir/mautrix-manager).
-    provisioning:
-        # Whether or not the provisioning API should be enabled.
-        enabled: true
-        # The prefix to use in the provisioning API endpoints.
-        prefix: /_matrix/provision/v1
-        # The shared secret to authorize users of the API.
-        # Set to "generate" to generate and save a new token.
-        shared_secret: generate
-
     # The unique ID of this appservice.
     id: instagram
     # Username of the appservice bot.
@@ -157,6 +146,17 @@ bridge:
     # except if the config file is not writable.
     resend_bridge_info: false
 
+    # Provisioning API part of the web server for automated portal creation and fetching information.
+    # Used by things like mautrix-manager (https://github.com/tulir/mautrix-manager).
+    provisioning:
+        # Whether or not the provisioning API should be enabled.
+        enabled: true
+        # The prefix to use in the provisioning API endpoints.
+        prefix: /_matrix/provision/v1
+        # The shared secret to authorize users of the API.
+        # Set to "generate" to generate and save a new token.
+        shared_secret: generate
+
     # The prefix for commands. Only required in non-management rooms.
     command_prefix: "!ig"
 

+ 1 - 0
mautrix_instagram/web/__init__.py

@@ -0,0 +1 @@
+from .provisioning_api import ProvisioningAPI

+ 181 - 0
mautrix_instagram/web/provisioning_api.py

@@ -0,0 +1,181 @@
+# 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 Awaitable, Dict
+import logging
+import asyncio
+import json
+
+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
+from mautrix.util.logging import TraceLogger
+
+from ..commands.auth import get_login_state
+from .. import user as u
+
+
+class ProvisioningAPI:
+    log: TraceLogger = logging.getLogger("mau.web.provisioning")
+    app: web.Application
+
+    def __init__(self, shared_secret: str) -> None:
+        self.app = web.Application()
+        self.shared_secret = shared_secret
+        self.app.router.add_get("/api/whoami", self.status)
+        self.app.router.add_options("/api/login", self.login_options)
+        self.app.router.add_options("/api/login/2fa", self.login_options)
+        self.app.router.add_options("/api/logout", self.login_options)
+        self.app.router.add_post("/api/login", self.login)
+        self.app.router.add_post("/api/login/2fa", self.login_2fa)
+        self.app.router.add_post("/api/logout", self.logout)
+
+    @property
+    def _acao_headers(self) -> Dict[str, str]:
+        return {
+            "Access-Control-Allow-Origin": "*",
+            "Access-Control-Allow-Headers": "Authorization, Content-Type",
+            "Access-Control-Allow-Methods": "POST, OPTIONS",
+        }
+
+    @property
+    def _headers(self) -> Dict[str, str]:
+        return {
+            **self._acao_headers,
+            "Content-Type": "application/json",
+        }
+
+    async def login_options(self, _: web.Request) -> web.Response:
+        return web.Response(status=200, headers=self._headers)
+
+    def check_token(self, request: web.Request) -> Awaitable['u.User']:
+        try:
+            token = request.headers["Authorization"]
+            token = token[len("Bearer "):]
+        except KeyError:
+            raise web.HTTPBadRequest(body='{"error": "Missing Authorization header"}',
+                                     headers=self._headers)
+        except IndexError:
+            raise web.HTTPBadRequest(body='{"error": "Malformed Authorization header"}',
+                                     headers=self._headers)
+        if token != self.shared_secret:
+            raise web.HTTPForbidden(body='{"error": "Invalid token"}', headers=self._headers)
+        try:
+            user_id = request.query["user_id"]
+        except KeyError:
+            raise web.HTTPBadRequest(body='{"error": "Missing user_id query param"}',
+                                     headers=self._headers)
+
+        return u.User.get_by_mxid(UserID(user_id))
+
+    async def status(self, request: web.Request) -> web.Response:
+        user = await self.check_token(request)
+        data = {
+            "permissions": user.permission_level,
+            "mxid": user.mxid,
+            "instagram": None,
+        }
+        if await user.is_logged_in():
+            try:
+                resp = await user.client.current_user()
+            except IGNotLoggedInError as e:
+                # TODO maybe don't always log out?
+                self.log.exception(f"Got error checking current user for %s, logging out. %s",
+                                   user.mxid, e.body.json())
+                await user.logout()
+            else:
+                data["instagram"] = resp.user.serialize()
+        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(body='{"error": "Malformed JSON"}', headers=self._headers)
+
+        try:
+            username = data["username"]
+            password = data["password"]
+        except KeyError:
+            raise web.HTTPBadRequest(body='{"error": "Missing keys"}', headers=self._headers)
+
+        api, state = await get_login_state(user, username)
+        try:
+            resp = await api.login(username, password)
+        except IGLoginTwoFactorRequiredError as e:
+            return web.json_response(data={
+                "status": "two-factor",
+                "response": e.body.serialize(),
+            }, status=202, headers=self._headers)
+        except IGLoginInvalidUserError:
+            return web.json_response(data={"status": "invalid-username"},
+                                     status=404, headers=self._headers)
+        except IGLoginBadPasswordError:
+            return web.json_response(data={"status": "incorrect-password"},
+                                     status=403, headers=self._headers)
+        return await self._finish_login(user, api, state, resp.logged_in_user)
+
+    async def login_2fa(self, request: web.Request) -> web.Response:
+        user = await self.check_token(request)
+        if not user.command_status or user.command_status["action"] != "Login":
+            raise web.HTTPNotFound(body='{"error": "No 2-factor login in progress}',
+                                   headers=self._headers)
+
+        try:
+            data = await request.json()
+        except json.JSONDecodeError:
+            raise web.HTTPBadRequest(body='{"error": "Malformed JSON"}', headers=self._headers)
+
+        try:
+            username = data["username"]
+            code = data["code"]
+            identifier = data["2fa_identifier"]
+            is_totp = data["is_totp"]
+        except KeyError:
+            raise web.HTTPBadRequest(body='{"error": "Missing keys"}', headers=self._headers)
+
+        api: AndroidAPI = user.command_status["api"]
+        state: AndroidState = user.command_status["state"]
+        try:
+            resp = await api.two_factor_login(username, code=code, identifier=identifier,
+                                              is_totp=is_totp)
+        except IGBad2FACodeError as e:
+            return web.json_response(data={
+                "status": "incorrect-2fa-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:
+        user.state = state
+        pl = state.device.payload
+        manufacturer, model = pl["manufacturer"], pl["model"]
+        asyncio.create_task(user.try_connect())
+        return web.json_response(data={
+            "status": "logged-in",
+            "device_displayname": f"{manufacturer} {model}",
+            "user": resp_user.serialize(),
+        }, status=200, headers=self._headers)
+
+    async def logout(self, request: web.Request) -> web.Response:
+        user = await self.check_token(request)
+        await user.logout()
+        return web.json_response({}, headers=self._acao_headers)