From 473668645450a8389e22ba6f3def015f60a7abe4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 23 Jul 2018 11:49:42 -0400 Subject: [PATCH] Implement Matrix login with web interface --- mautrix_telegram/commands/auth.py | 58 ++++++++++++-- mautrix_telegram/puppet.py | 36 +++++++-- mautrix_telegram/web/common/auth_api.py | 28 +++++++ mautrix_telegram/web/provisioning/__init__.py | 4 + mautrix_telegram/web/public/__init__.py | 69 ++++++++++++++-- mautrix_telegram/web/public/login.css | 53 ++++++++++++- mautrix_telegram/web/public/login.html.mako | 10 +-- .../web/public/matrix-login.html.mako | 78 +++++++++++++++++++ 8 files changed, 311 insertions(+), 25 deletions(-) create mode 100644 mautrix_telegram/web/public/matrix-login.html.mako diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py index 33c88cb7..9d52ab0d 100644 --- a/mautrix_telegram/commands/auth.py +++ b/mautrix_telegram/commands/auth.py @@ -49,14 +49,62 @@ async def ping_bot(evt: CommandEvent): "To use the bot, simply invite it to a portal room.") +@command_handler(needs_auth=True, + help_section=SECTION_AUTH, + help_text="Revert your Telegram account's Matrix puppet to use the default Matrix " + "account.") +async def logout_matrix(evt: CommandEvent): + puppet = pu.Puppet.get(evt.sender.tgid) + if not puppet.is_real_user: + return await evt.reply("You are not logged in with your Matrix account.") + await puppet.switch_mxid(None, None) + await evt.reply("Reverted your Telegram account's Matrix puppet back to the default.") + + @command_handler(needs_auth=True, management_only=True, help_section=SECTION_AUTH, - help_args="<_token_>", help_text="Replace your Telegram account's Matrix puppet with your own Matrix " "account") async def login_matrix(evt: CommandEvent): puppet = pu.Puppet.get(evt.sender.tgid) - resp = puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) + if puppet.is_real_user: + return await evt.reply("You have already logged in with your Matrix account. " + "Log out with `$cmdprefix+sp logout-matrix` first.") + allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True) + if allow_matrix_login: + evt.sender.command_status = { + "next": enter_matrix_token, + "action": "Matrix login", + } + if evt.config["appservice.public.enabled"]: + prefix = evt.config["appservice.public.external"] + url = f"{prefix}/matrix-login?token={evt.public_website.make_token(evt.sender.mxid, '/matrix-login')}" + if allow_matrix_login: + return await evt.reply( + "This bridge instance allows you to log in inside or outside Matrix.\n\n" + "If you would like to log in within Matrix, please send your Matrix access token " + "here.\n" + f"If you would like to log in outside of Matrix, [click here]({url}).\n\n" + "Logging in outside of Matrix is recommended, because in-Matrix login would save " + "your access token in the message history.") + return await evt.reply("This bridge instance does not allow logging in inside Matrix.\n\n" + f"Please visit [the login page]({url}) to log in.") + elif allow_matrix_login: + return await evt.reply( + "This bridge instance does not allow you to log in outside of Matrix.\n\n" + "Please send your Matrix access token here to log in.") + return await evt.reply("This bridge instance has been configured to not allow logging in.") + + +async def enter_matrix_token(evt: CommandEvent): + evt.sender.command_status = None + + puppet = pu.Puppet.get(evt.sender.tgid) + if puppet.is_real_user: + return await evt.reply("You have already logged in with your Matrix account. " + "Log out with `$cmdprefix+sp logout-matrix` first.") + + resp = await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) if resp == 2: return await evt.reply("You can only log in as your own Matrix user.") elif resp == 1: @@ -130,8 +178,8 @@ async def login(evt: CommandEvent): if evt.config["appservice.public.enabled"]: prefix = evt.config["appservice.public.external"] - url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid)}" - if evt.config.get("bridge.allow_matrix_login", True): + url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid, '/login')}" + if allow_matrix_login: return await evt.reply( "This bridge instance allows you to log in inside or outside Matrix.\n\n" "If you would like to log in within Matrix, please send your phone number or bot " @@ -144,7 +192,7 @@ async def login(evt: CommandEvent): elif allow_matrix_login: return await evt.reply( "This bridge instance does not allow you to log in outside of Matrix.\n\n" - "Please send your phone number or bot aut token here to start the login process.") + "Please send your phone number or bot auth token here to start the login process.") return await evt.reply("This bridge instance has been configured to not allow logging in.") diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index e128561f..222fd972 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -21,7 +21,7 @@ import logging import asyncio from telethon.tl.types import UserProfilePhoto -from mautrix_appservice import AppService, IntentAPI, MatrixRequestError +from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError from .db import Puppet as DBPuppet from . import util, matrix @@ -67,15 +67,19 @@ class Puppet: if self.custom_mxid: self.by_custom_mxid[self.custom_mxid] = self + @property + def tgid(self): + return self.id + + async def is_logged_in(self): + return True + + # region Custom puppet management def refresh_intents(self): self.is_real_user = self.custom_mxid and self.access_token self.intent = (self.az.intent.user(self.custom_mxid, self.access_token) if self.is_real_user else self.default_mxid_intent) - @property - def tgid(self): - return self.id - async def switch_mxid(self, access_token, mxid): prev_mxid = self.custom_mxid self.custom_mxid = mxid @@ -91,7 +95,9 @@ class Puppet: except KeyError: pass self.mxid = self.custom_mxid or self.default_mxid - self.by_custom_mxid[self.mxid] = self + if self.mxid != self.default_mxid: + self.by_custom_mxid[self.mxid] = self + await self.leave_rooms_with_default_user() self.save() return 0 @@ -111,6 +117,14 @@ class Puppet: asyncio.ensure_future(self.sync(), loop=self.loop) return 0 + async def leave_rooms_with_default_user(self): + for room_id in await self.default_mxid_intent.get_joined_rooms(): + try: + await self.default_mxid_intent.leave_room(room_id) + await self.intent.ensure_joined(room_id) + except (IntentError, MatrixRequestError): + pass + def create_sync_filter(self) -> Awaitable[str]: return self.intent.client.create_filter(self.custom_mxid, { "room": { @@ -187,8 +201,8 @@ class Puppet: await asyncio.sleep(wait) self.log.debug(f"Syncer for custom puppet {custom_mxid} stopped.") - async def is_logged_in(self): - return True + # endregion + # region DB conversion @property def db_instance(self): @@ -220,6 +234,8 @@ class Puppet: self.db_instance.matrix_registered = self.is_registered self.db.commit() + # endregion + # region Info updating def similarity(self, query): username_similarity = (SequenceMatcher(None, self.username, query).ratio() if self.username else 0) @@ -299,6 +315,9 @@ class Puppet: return True return False + # endregion + # region Getters + @classmethod def get(cls, id, create=True) -> "Optional[Puppet]": try: @@ -387,6 +406,7 @@ class Puppet: return cls.from_db(puppet) return None + # endregion def init(context): diff --git a/mautrix_telegram/web/common/auth_api.py b/mautrix_telegram/web/common/auth_api.py index 70b66136..24fa74e9 100644 --- a/mautrix_telegram/web/common/auth_api.py +++ b/mautrix_telegram/web/common/auth_api.py @@ -23,6 +23,8 @@ from telethon.errors import * from ...commands.auth import enter_password from ...util import format_duration +from ...puppet import Puppet +from ...user import User class AuthAPI(abc.ABC): @@ -36,6 +38,32 @@ class AuthAPI(abc.ABC): errcode=""): raise NotImplementedError() + @abstractmethod + def get_mx_login_response(self, status=200, state="", username="", mxid="", message="", + error="", errcode=""): + raise NotImplementedError() + + async def post_matrix_token(self, user: User, token): + puppet = Puppet.get(user.tgid) + if puppet.is_real_user: + return self.get_mx_login_response(state="already-logged-in", status=409, + error="You have already logged in with your Matrix " + "account.", errcode="already-logged-in") + + resp = await puppet.switch_mxid(token, user.mxid) + if resp == 2: + return self.get_mx_login_response(status=403, errcode="only-login-self", + error="You can only log in as your own Matrix user.") + elif resp == 1: + return self.get_mx_login_response(status=401, errcode="invalid-access-token", + error="Failed to verify access token.") + + return self.get_mx_login_response(mxid=user.mxid, status=200, state="logged-in") + + async def post_matrix_password(self, user, password): + return self.get_mx_login_response(mxid=user.mxid, status=501, error="Not yet implemented", + errcode="not-yet-implemented") + async def post_login_phone(self, user, phone): try: await user.client.sign_in(phone or "+123") diff --git a/mautrix_telegram/web/provisioning/__init__.py b/mautrix_telegram/web/provisioning/__init__.py index 63bb208d..731a91ff 100644 --- a/mautrix_telegram/web/provisioning/__init__.py +++ b/mautrix_telegram/web/provisioning/__init__.py @@ -297,6 +297,10 @@ class ProvisioningAPI(AuthAPI): "errcode": errcode, }, status=status) + def get_mx_login_response(self, status=200, state="", username="", mxid="", message="", + error="", errcode=""): + raise NotImplementedError() + def get_login_response(self, status=200, state="", username="", mxid="", message="", error="", errcode="") -> web.Response: if username: diff --git a/mautrix_telegram/web/public/__init__.py b/mautrix_telegram/web/public/__init__.py index fb5f6de7..be6bb3b0 100644 --- a/mautrix_telegram/web/public/__init__.py +++ b/mautrix_telegram/web/public/__init__.py @@ -24,6 +24,7 @@ import time from ...util import sign_token, verify_token from ...user import User +from ...puppet import Puppet from ..common import AuthAPI @@ -38,28 +39,35 @@ class PublicBridgeWebsite(AuthAPI): self.login = Template( pkg_resources.resource_string("mautrix_telegram", "web/public/login.html.mako")) + self.mx_login = Template( + pkg_resources.resource_string("mautrix_telegram", "web/public/matrix-login.html.mako")) + self.app = web.Application(loop=loop) self.app.router.add_route("GET", "/login", self.get_login) self.app.router.add_route("POST", "/login", self.post_login) + self.app.router.add_route("GET", "/matrix-login", self.get_matrix_login) + self.app.router.add_route("POST", "/matrix-login", self.post_matrix_login) self.app.router.add_static("/", pkg_resources.resource_filename("mautrix_telegram", "web/public/")) - def make_token(self, mxid, expires_in=900): + def make_token(self, mxid, endpoint="/login", expires_in=900): return sign_token(self.secret_key, { "mxid": mxid, + "endpoint": endpoint, "expiry": int(time.time()) + expires_in, }) - def verify_token(self, token): + def verify_token(self, token, endpoint="/login"): token = verify_token(self.secret_key, token) - if token and token.get("expiry", 0) > int(time.time()): + if token and (token.get("expiry", 0) > int(time.time()) and + token.get("endpoint", None) == endpoint): return token.get("mxid", None) return None async def get_login(self, request): state = "bot_token" if request.rel_url.query.get("mode", "") == "bot" else "request" - mxid = self.verify_token(request.rel_url.query.get("token", None)) + mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/login") if not mxid: return self.get_login_response(status=401, state="invalid-token") user = User.get_by_mxid(mxid, create=False) if mxid else None @@ -75,14 +83,65 @@ class PublicBridgeWebsite(AuthAPI): return self.get_login_response(mxid=user.mxid, username=user.username) + async def get_matrix_login(self, request): + mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/matrix-login") + if not mxid: + return self.get_mx_login_response(status=401, state="invalid-token") + user = User.get_by_mxid(mxid, create=False) if mxid else None + + if not user: + return self.get_mx_login_response(mxid=mxid) + elif not user.puppet_whitelisted: + return self.get_mx_login_response(mxid=user.mxid, error="You are not whitelisted.", + status=403) + await user.ensure_started() + if not await user.is_logged_in(): + return self.get_mx_login_response(mxid=user.mxid, status=403, + error="You are not logged in to Telegram.") + + puppet = Puppet.get(user.tgid) + if puppet.is_real_user: + return self.get_mx_login_response(state="already-logged-in", status=409) + + return self.get_mx_login_response(mxid=user.mxid) + def get_login_response(self, status=200, state="", username="", mxid="", message="", error="", errcode=""): return web.Response(status=status, content_type="text/html", text=self.login.render(username=username, state=state, error=error, message=message, mxid=mxid)) + def get_mx_login_response(self, status=200, state="", username="", mxid="", message="", + error="", errcode=""): + return web.Response(status=status, content_type="text/html", + text=self.mx_login.render(username=username, state=state, error=error, + message=message, mxid=mxid)) + + async def post_matrix_login(self, request): + mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/matrix-login") + if not mxid: + return self.get_mx_login_response(status=401, state="invalid-token") + + data = await request.post() + + user = await User.get_by_mxid(mxid).ensure_started() + if not user.puppet_whitelisted: + return self.get_mx_login_response(mxid=user.mxid, error="You are not whitelisted.", + status=403) + elif not await user.is_logged_in(): + return self.get_mx_login_response(mxid=user.mxid, status=403, + error="You are not logged in to Telegram.") + mode = data.get("mode", "access_token") + if mode == "password": + return await self.post_matrix_password(user, data["value"]) + elif mode == "access_token": + return await self.post_matrix_token(user, data["value"]) + return self.get_mx_login_response(mxid=user.mxid, status=400, + error="You must provide an access token or " + "password.") + async def post_login(self, request): - mxid = self.verify_token(request.rel_url.query.get("token", None)) + mxid = self.verify_token(request.rel_url.query.get("token", None), endpoint="/login") if not mxid: return self.get_login_response(status=401, state="invalid-token") diff --git a/mautrix_telegram/web/public/login.css b/mautrix_telegram/web/public/login.css index c7ade95b..7b035792 100644 --- a/mautrix_telegram/web/public/login.css +++ b/mautrix_telegram/web/public/login.css @@ -19,8 +19,8 @@ form > div { display: none; } -form[data-status="request"] > div.status-request, -form[data-status="code"] > div.status-code, +form[data-status="request"] > div.status-request, +form[data-status="code"] > div.status-code, form[data-status="password"] > div.status-password { display: initial; } @@ -48,3 +48,52 @@ form[data-status="password"] > div.status-password { background-color: #d4edda; color: #155724; } + +[type="checkbox"], [type="radio"] { + position: absolute; + opacity: 0; +} + +[type="checkbox"] + label, [type="radio"] + label { + position: relative; + padding-left: 2.5rem; + cursor: pointer; + display: inline-block; +} + +[type="checkbox"] + label:before, [type="radio"] + label:before { + content: ''; + position: absolute; + left: 0; + top: 0.4rem; + width: 1.8rem; + height: 1.8rem; + border: 0.1rem solid #d1d1d1; +} + +[type="radio"] + label:before, [type="radio"] + label:after { + border-radius: 50%; +} + +[type="checkbox"]:checked + label:after, +[type="radio"]:checked + label:after { + content: ''; + width: 0.8rem; + height: 0.8rem; + background: #9b4dca; + position: absolute; + top: 0.9rem; + left: 0.5rem; +} + +[type="radio"]:disabled + label:before, [type="checkbox"]:disabled + label:before { + background-color: #d1d1d1; +} + +[type="radio"]:disabled + label, [type="checkbox"]:disabled + label { + color: #d1d1d1; +} + +[type="radio"]:disabled:checked + label:after, [type="checkbox"]:disabled:checked + label:after { + background: #606c76; +} diff --git a/mautrix_telegram/web/public/login.html.mako b/mautrix_telegram/web/public/login.html.mako index f00b6a69..96db7bac 100644 --- a/mautrix_telegram/web/public/login.html.mako +++ b/mautrix_telegram/web/public/login.html.mako @@ -18,9 +18,9 @@ along with this program. If not, see . - Mautrix-Telegram bridge + Login - Mautrix-Telegram bridge - + @@ -40,10 +40,10 @@ along with this program. If not, see . function goBack() { let params = new URLSearchParams(location.search.slice(1)) - const mxid = params.get("mxid") + const token = params.get("token") params = new URLSearchParams() - if (mxid) { - params.set("mxid", mxid) + if (token) { + params.set("token", token) } location.replace(location.href.split("?")[0] + "?" + params.toString()) } diff --git a/mautrix_telegram/web/public/matrix-login.html.mako b/mautrix_telegram/web/public/matrix-login.html.mako new file mode 100644 index 00000000..6135cb0d --- /dev/null +++ b/mautrix_telegram/web/public/matrix-login.html.mako @@ -0,0 +1,78 @@ + + + + + Matrix login - Mautrix-Telegram bridge + + + + + + + + + + + +
+ % if state == "logged-in": +

Logged in successfully!

+

+ Logged in as ${mxid}. + You can now close this page. +

+ % elif state == "already-logged-in": +

You're already logged in!

+

+ If you want to log in with another account, log out using the + logout-matrix management command first. +

+ % elif state == "invalid-token": +

Invalid or expired token

+
Please ask the bridge bot for a new login link.
+ % else: +

Log in to Matrix

+ % if error: +
${error}
+ % endif + % if message: +
${message}
+ % endif +
+
+ + + + +
+ +
+ + + + + +
+
+ % endif +
+ +