From 684e5bcf3849f8b963c2724b0699a0a422f7a2ba Mon Sep 17 00:00:00 2001
From: Tulir Asokan <tulir@maunium.net>
Date: Tue, 29 Dec 2020 16:33:33 +0200
Subject: [PATCH] Add basic websocket login API

---
 mautrix_amp/user.py                 |  1 -
 mautrix_amp/web/provisioning_api.py | 81 ++++++++++++++++-------------
 puppet/src/puppet.js                |  8 ++-
 3 files changed, 51 insertions(+), 39 deletions(-)

diff --git a/mautrix_amp/user.py b/mautrix_amp/user.py
index b84e7fc..2584fcc 100644
--- a/mautrix_amp/user.py
+++ b/mautrix_amp/user.py
@@ -54,7 +54,6 @@ class User(DBUser, BaseUser):
         self._metric_value = defaultdict(lambda: False)
         self._connection_check_task = None
         self.client = None
-        self.username = None
         self.intent = None
 
     @classmethod
diff --git a/mautrix_amp/web/provisioning_api.py b/mautrix_amp/web/provisioning_api.py
index ab8370b..e3da47e 100644
--- a/mautrix_amp/web/provisioning_api.py
+++ b/mautrix_amp/web/provisioning_api.py
@@ -13,9 +13,9 @@
 #
 # 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, Optional
 import logging
-import json
+import asyncio
 
 from aiohttp import web
 
@@ -33,16 +33,14 @@ class ProvisioningAPI:
         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_post("/api/login", self.login)
-        self.app.router.add_post("/api/logout", self.logout)
+        self.app.router.add_get("/api/login", self.login)
 
     @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",
+            "Access-Control-Allow-Methods": "GET",
         }
 
     @property
@@ -55,22 +53,39 @@ class ProvisioningAPI:
     async def login_options(self, _: web.Request) -> web.Response:
         return web.Response(status=200, headers=self._headers)
 
+    @staticmethod
+    def _get_ws_token(request: web.Request) -> Optional[str]:
+        if not request.path.endswith("/login"):
+            return None
+
+        try:
+            auth_parts = request.headers["Sec-WebSocket-Protocol"].split(",")
+        except KeyError:
+            return None
+        for part in auth_parts:
+            part = part.strip()
+            if part.startswith("net.maunium.amp.auth-"):
+                return part[len("net.maunium.amp.auth-"):]
+        return None
+
     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)
+            token = self._get_ws_token(request)
+            if not token:
+                raise web.HTTPBadRequest(text='{"error": "Missing Authorization header"}',
+                                         headers=self._headers)
         except IndexError:
-            raise web.HTTPBadRequest(body='{"error": "Malformed Authorization header"}',
+            raise web.HTTPBadRequest(text='{"error": "Malformed Authorization header"}',
                                      headers=self._headers)
         if token != self.shared_secret:
-            raise web.HTTPForbidden(body='{"error": "Invalid token"}', headers=self._headers)
+            raise web.HTTPForbidden(text='{"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"}',
+            raise web.HTTPBadRequest(text='{"error": "Missing user_id query param"}',
                                      headers=self._headers)
 
         return u.User.get_by_mxid(UserID(user_id))
@@ -78,37 +93,31 @@ class ProvisioningAPI:
     async def status(self, request: web.Request) -> web.Response:
         user = await self.check_token(request)
         data = {
-            "permissions": user.permission_level,
             "mxid": user.mxid,
-            "twitter": None,
+            "amp": {
+                "connected": True,
+            } if await user.is_logged_in() else None,
         }
-        if await user.is_logged_in():
-            data["twitter"] = (await user.get_info()).serialize()
         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.WebSocketResponse:
         user = await self.check_token(request)
 
-        try:
-            data = await request.json()
-        except json.JSONDecodeError:
-            raise web.HTTPBadRequest(body='{"error": "Malformed JSON"}', headers=self._headers)
+        status = await user.client.start()
+        if status.is_logged_in:
+            raise web.HTTPConflict(text='{"error": "Already logged in"}', headers=self._headers)
 
+        ws = web.WebSocketResponse(protocols=["net.maunium.amp.login"])
+        await ws.prepare(request)
         try:
-            auth_token = data["auth_token"]
-            csrf_token = data["csrf_token"]
-        except KeyError:
-            raise web.HTTPBadRequest(body='{"error": "Missing keys"}', headers=self._headers)
-
-        try:
-            await user.connect(auth_token=auth_token, csrf_token=csrf_token)
+            async for url in user.client.login():
+                self.log.debug("Sending QR URL %s to websocket", url)
+                await ws.send_json({"url": url})
         except Exception:
-            self.log.debug("Failed to log in", exc_info=True)
-            raise web.HTTPUnauthorized(body='{"error": "Twitter authorization failed"}',
-                                       headers=self._headers)
-        return web.Response(body='{}', 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)
+            await ws.send_json({"success": False})
+            self.log.exception("Error logging in")
+        else:
+            await ws.send_json({"success": True})
+            asyncio.create_task(user.sync())
+        await ws.close()
+        return ws
diff --git a/puppet/src/puppet.js b/puppet/src/puppet.js
index 91867e3..038b7ee 100644
--- a/puppet/src/puppet.js
+++ b/puppet/src/puppet.js
@@ -115,8 +115,12 @@ export default class MessagesPuppeteer {
 			return
 		}
 		const qrSelector = "mw-authentication-container mw-qr-code"
-		this.log("Clicking Remember Me button")
-		await this.page.click("mat-slide-toggle:not(.mat-checked) > label")
+		if (!await this.page.$("mat-slide-toggle.mat-checked")) {
+			this.log("Clicking Remember Me button")
+			await this.page.click("mat-slide-toggle:not(.mat-checked) > label")
+		} else {
+			this.log("Remember Me button already clicked")
+		}
 		this.log("Fetching current QR code")
 		const currentQR = await this.page.$eval(qrSelector,
 			element => element.getAttribute("data-qr-code"))
-- 
GitLab