Require authentication for web login. Fixes #163

This commit is contained in:
Tulir Asokan
2018-07-14 16:00:20 +03:00
parent 90e7a09b7e
commit d97281bcdc
11 changed files with 130 additions and 37 deletions
+7 -4
View File
@@ -85,18 +85,21 @@ appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
config["appservice.as_token"], config["appservice.hs_token"], config["appservice.as_token"], config["appservice.hs_token"],
config["appservice.bot_username"], log="mau.as", loop=loop, config["appservice.bot_username"], log="mau.as", loop=loop,
verify_ssl=config["homeserver.verify_ssl"]) verify_ssl=config["homeserver.verify_ssl"])
public_website = None
context = Context(appserv, db_session, config, loop, None, None, session_container) provisioning_api = None
if config["appservice.public.enabled"]: if config["appservice.public.enabled"]:
public = PublicBridgeWebsite(loop) public_website = PublicBridgeWebsite(loop)
appserv.app.add_subapp(config["appservice.public.prefix"] or "/public", public.app) appserv.app.add_subapp(config["appservice.public.prefix"] or "/public", public_website.app)
if config["appservice.provisioning.enabled"]: if config["appservice.provisioning.enabled"]:
provisioning_api = ProvisioningAPI(config, loop) provisioning_api = ProvisioningAPI(config, loop)
appserv.app.add_subapp(config["appservice.provisioning.prefix"] or "/_matrix/provisioning", appserv.app.add_subapp(config["appservice.provisioning.prefix"] or "/_matrix/provisioning",
provisioning_api.app) provisioning_api.app)
context = Context(appserv, db_session, config, loop, None, None, session_container, public_website,
provisioning_api)
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start: with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
init_db(db_session) init_db(db_session)
init_abstract_user(context) init_abstract_user(context)
+1 -1
View File
@@ -114,7 +114,7 @@ async def login(evt: CommandEvent):
if evt.config["appservice.public.enabled"]: if evt.config["appservice.public.enabled"]:
prefix = evt.config["appservice.public.external"] prefix = evt.config["appservice.public.external"]
url = f"{prefix}/login?mxid={evt.sender.mxid}" url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid)}"
if evt.config.get("bridge.allow_matrix_login", True): if evt.config.get("bridge.allow_matrix_login", True):
return await evt.reply( return await evt.reply(
"This bridge instance allows you to log in inside or outside Matrix.\n\n" "This bridge instance allows you to log in inside or outside Matrix.\n\n"
+4 -3
View File
@@ -22,8 +22,7 @@ import logging
from telethon.errors import FloodWaitError from telethon.errors import FloodWaitError
from ..util import format_duration from ..util import format_duration
from ..context import Context from .. import user as u, context as c
from .. import user as u
command_handlers = {} # type: Dict[str, CommandHandler] command_handlers = {} # type: Dict[str, CommandHandler]
@@ -45,6 +44,7 @@ class CommandEvent:
self.loop = processor.loop self.loop = processor.loop
self.tgbot = processor.tgbot self.tgbot = processor.tgbot
self.config = processor.config self.config = processor.config
self.public_website = processor.public_website
self.command_prefix = processor.command_prefix self.command_prefix = processor.command_prefix
self.room_id = room self.room_id = room
self.sender = sender self.sender = sender
@@ -131,8 +131,9 @@ def command_handler(_func: Optional[Callable[[CommandEvent], None]] = None, *, n
class CommandProcessor: class CommandProcessor:
log = logging.getLogger("mau.commands") log = logging.getLogger("mau.commands")
def __init__(self, context: Context): def __init__(self, context: c.Context):
self.az, self.db, self.config, self.loop, self.tgbot = context self.az, self.db, self.config, self.loop, self.tgbot = context
self.public_website = context.public_website
self.command_prefix = self.config["bridge.command_prefix"] self.command_prefix = self.config["bridge.command_prefix"]
async def handle(self, room: str, sender: u.User, command: str, args: List[str], async def handle(self, room: str, sender: u.User, command: str, args: List[str],
+21 -8
View File
@@ -14,17 +14,30 @@
# #
# 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/>.
import asyncio
from sqlalchemy.orm import scoped_session
from alchemysession import AlchemySessionContainer
from mautrix_appservice import AppService
class Context: class Context:
def __init__(self, az, db, config, loop, bot, mx, session_container): def __init__(self, az, db, config, loop, bot, mx, session_container, public_website,
self.az = az provisioning_api):
self.db = db from .web import PublicBridgeWebsite, ProvisioningAPI
self.config = config from .config import Config
self.loop = loop from .bot import Bot
self.bot = bot from .matrix import MatrixHandler
self.mx = mx
self.session_container = session_container self.az = az # type: AppService
self.db = db # type: scoped_session
self.config = config # type: Config
self.loop = loop # type: asyncio.AbstractEventLoop
self.bot = bot # type: Bot
self.mx = mx # type: MatrixHandler
self.session_container = session_container # type: AlchemySessionContainer
self.public_website = public_website # type: PublicBridgeWebsite
self.provisioning_api = provisioning_api # type: ProvisioningAPI
def __iter__(self): def __iter__(self):
yield self.az yield self.az
+2 -2
View File
@@ -1,9 +1,9 @@
from .from_matrix import (matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram, from .from_matrix import (matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram,
init_mx) init_mx)
from .from_telegram import (telegram_reply_to_matrix, telegram_to_matrix, init_tg) from .from_telegram import (telegram_reply_to_matrix, telegram_to_matrix, init_tg)
from ..context import Context from .. import context as c
def init(context: Context): def init(context: c.Context):
init_mx(context) init_mx(context)
init_tg(context) init_tg(context)
+2 -3
View File
@@ -27,8 +27,7 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M
MessageEntityItalic, MessageEntityCode, MessageEntityPre, MessageEntityItalic, MessageEntityCode, MessageEntityPre,
MessageEntityBotCommand, TypeMessageEntity) MessageEntityBotCommand, TypeMessageEntity)
from ..context import Context from .. import user as u, puppet as pu, portal as po, context as c
from .. import user as u, puppet as pu, portal as po
from ..db import Message as DBMessage from ..db import Message as DBMessage
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html, from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text, html_to_unicode) trim_reply_fallback_text, html_to_unicode)
@@ -352,7 +351,7 @@ def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], st
return entities, replacer return entities, replacer
def init_mx(context: Context): def init_mx(context: c.Context):
global plain_mention_regex, should_bridge_plaintext_highlights global plain_mention_regex, should_bridge_plaintext_highlights
config = context.config config = context.config
dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)") dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)")
+2 -3
View File
@@ -33,8 +33,7 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName,
from mautrix_appservice import MatrixRequestError from mautrix_appservice import MatrixRequestError
from mautrix_appservice.intent_api import IntentAPI from mautrix_appservice.intent_api import IntentAPI
from .. import user as u, puppet as pu, portal as po from .. import user as u, puppet as pu, portal as po, context as c
from ..context import Context
from ..db import Message as DBMessage from ..db import Message as DBMessage
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html, from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text, unicode_to_html) trim_reply_fallback_text, unicode_to_html)
@@ -321,6 +320,6 @@ def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
return False return False
def init_tg(context: Context): def init_tg(context: c.Context):
global should_highlight_edits global should_highlight_edits
should_highlight_edits = htmldiff and context.config["bridge.highlight_edits"] should_highlight_edits = htmldiff and context.config["bridge.highlight_edits"]
+1
View File
@@ -1,2 +1,3 @@
from .file_transfer import transfer_file_to_matrix, convert_image from .file_transfer import transfer_file_to_matrix, convert_image
from .format_duration import format_duration from .format_duration import format_duration
from .signed_token import sign_token, verify_token
+53
View File
@@ -0,0 +1,53 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 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
import json
import base64
import hashlib
def _get_checksum(key: str, payload: bytes) -> str:
hasher = hashlib.sha256()
hasher.update(payload)
hasher.update(key.encode("utf-8"))
checksum = hasher.hexdigest()
return checksum
def sign_token(key: str, payload: dict) -> str:
payload = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
checksum = _get_checksum(key, payload)
return f"{checksum}:{payload.decode('utf-8')}"
def verify_token(key: str, data: str) -> Optional[dict]:
if not data:
return None
try:
checksum, payload = data.split(":", 1)
except ValueError:
return None
if checksum != _get_checksum(key, payload.encode("utf-8")):
return None
payload = base64.urlsafe_b64decode(payload).decode("utf-8")
try:
return json.loads(payload)
except json.JSONDecodeError:
return None
+31 -9
View File
@@ -18,7 +18,11 @@ from aiohttp import web
from mako.template import Template from mako.template import Template
import pkg_resources import pkg_resources
import logging import logging
import random
import string
import time
from ...util import sign_token, verify_token
from ...user import User from ...user import User
from ..common import AuthAPI from ..common import AuthAPI
@@ -28,6 +32,8 @@ class PublicBridgeWebsite(AuthAPI):
def __init__(self, loop): def __init__(self, loop):
super().__init__(loop) super().__init__(loop)
self.secret_key = "".join(
random.choice(string.ascii_lowercase + string.digits) for _ in range(64))
self.login = Template( self.login = Template(
pkg_resources.resource_string("mautrix_telegram", "web/public/login.html.mako")) pkg_resources.resource_string("mautrix_telegram", "web/public/login.html.mako"))
@@ -38,10 +44,24 @@ class PublicBridgeWebsite(AuthAPI):
self.app.router.add_static("/", pkg_resources.resource_filename("mautrix_telegram", self.app.router.add_static("/", pkg_resources.resource_filename("mautrix_telegram",
"web/public/")) "web/public/"))
async def get_login(self, request): def make_token(self, mxid, expires_in=900):
state = "token" if request.rel_url.query.get("mode", "") == "bot" else "request" return sign_token(self.secret_key, {
"mxid": mxid,
"expiry": int(time.time()) + expires_in,
})
mxid = request.rel_url.query.get("mxid", None) def verify_token(self, token):
token = verify_token(self.secret_key, token)
if token and token.get("expiry", 0) > int(time.time()):
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))
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 user = User.get_by_mxid(mxid, create=False) if mxid else None
if not user: if not user:
@@ -62,11 +82,13 @@ class PublicBridgeWebsite(AuthAPI):
message=message, mxid=mxid)) message=message, mxid=mxid))
async def post_login(self, request): async def post_login(self, request):
data = await request.post() mxid = self.verify_token(request.rel_url.query.get("token", None))
if "mxid" not in data: if not mxid:
return self.get_login_response(error="Please enter your Matrix ID.", status=400) return self.get_login_response(status=401, state="invalid-token")
user = await User.get_by_mxid(data["mxid"]).ensure_started(even_if_no_session=True) data = await request.post()
user = await User.get_by_mxid(mxid).ensure_started(even_if_no_session=True)
if not user.puppet_whitelisted: if not user.puppet_whitelisted:
return self.get_login_response(mxid=user.mxid, error="You are not whitelisted.", return self.get_login_response(mxid=user.mxid, error="You are not whitelisted.",
status=403) status=403)
@@ -77,8 +99,8 @@ class PublicBridgeWebsite(AuthAPI):
if "phone" in data: if "phone" in data:
return await self.post_login_phone(user, data["phone"]) return await self.post_login_phone(user, data["phone"])
elif "token" in data: elif "bot_token" in data:
return await self.post_login_token(user, data["token"]) return await self.post_login_token(user, data["bot_token"])
elif "code" in data: elif "code" in data:
resp = await self.post_login_code(user, data["code"], resp = await self.post_login_code(user, data["code"],
password_in_data="password" in data) password_in_data="password" in data)
+6 -4
View File
@@ -76,6 +76,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
management command first. management command first.
</p> </p>
% endif % endif
% elif state == "invalid-token":
<h1>Invalid or expired token</h1>
<div class="error">Please ask the bridge bot for a new login link.</div>
% else: % else:
<h1>Log in to Telegram</h1> <h1>Log in to Telegram</h1>
% if error: % if error:
@@ -87,8 +90,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<form method="post"> <form method="post">
<fieldset> <fieldset>
<label for="mxid">Matrix ID</label> <label for="mxid">Matrix ID</label>
<input type="text" id="mxid" name="mxid" placeholder="Enter Matrix ID" <input type="text" id="mxid" name="mxid" disabled value="${mxid}"/>
value="${mxid}"/>
% if state == "request": % if state == "request":
<label for="value">Phone number</label> <label for="value">Phone number</label>
<input type="tel" id="value" name="phone" placeholder="Enter phone number"/> <input type="tel" id="value" name="phone" placeholder="Enter phone number"/>
@@ -96,9 +98,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button class="button-clear" type="button" onclick="switchToBotLogin()"> <button class="button-clear" type="button" onclick="switchToBotLogin()">
Use bot token Use bot token
</button> </button>
% elif state == "token": % elif state == "bot_token":
<label for="value">Bot token</label> <label for="value">Bot token</label>
<input type="text" id="value" name="token" placeholder="Enter bot API token"/> <input type="text" id="value" name="bot_token" placeholder="Enter bot API token"/>
<button type="submit">Sign in</button> <button type="submit">Sign in</button>
% elif state == "code": % elif state == "code":
<label for="value">Phone code</label> <label for="value">Phone code</label>