Merge pull request #73 from tulir/independent-login

Add support for out-of-Matrix login
This commit is contained in:
Tulir Asokan
2018-02-22 00:04:49 +02:00
committed by GitHub
13 changed files with 389 additions and 47 deletions
+19 -4
View File
@@ -14,6 +14,18 @@ appservice:
hostname: localhost
port: 8080
# Public part of web server for out-of-Matrix interaction with the bridge.
# Used for things like login if the user wants to make sure the 2FA password isn't stored in
# the HS database.
public:
# Whether or not the public-facing endpoints should be enabled.
enabled: true
# The prefix to use in the public-facing endpoints.
prefix: /public
# The base URL where the public-facing endpoints are available. The prefix is not added
# implicitly.
external: https://example.com/public
# Whether or not to enable debug messages in the console.
debug: false
@@ -60,18 +72,21 @@ bridge:
# Whether or not to use native Matrix replies. At the time of writing, only riot-web supports
# replies and the format of them is subject to change.
native_replies: True
native_replies: true
# If native replies are disabled, should the custom replies contain a link to the message being
# replied to?
link_in_reply: False
link_in_reply: false
# Show message editing as a reply to the original message.
# If this is false, message edits are not shown at all, as Matrix does not support editing yet.
edits_as_replies: False
edits_as_replies: false
# Whether or not Matrix bot messages (type m.notice) should be bridged.
bridge_notices: False
bridge_notices: false
# The maximum number of simultaneous Telegram deletions to handle.
# A large number of simultaneous redactions could put strain on your homeserver.
max_telegram_delete: 10
# Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix
# login website (see appservice.public config section)
allow_matrix_login: true
# The prefix for commands. Only required in non-management rooms.
command_prefix: "!tg"
-1
View File
@@ -16,7 +16,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# Partly based on github.com/Cadair/python-appservice-framework (MIT license)
from functools import partial
from contextlib import contextmanager
from aiohttp import web
import aiohttp
+6
View File
@@ -34,6 +34,7 @@ from .user import init as init_user, User
from .bot import init as init_bot
from .portal import init as init_portal
from .puppet import init as init_puppet
from .public import PublicBridgeWebsite
from .context import Context
log = logging.getLogger("mau")
@@ -76,6 +77,7 @@ Base.metadata.bind = db_engine
Base.metadata.create_all()
loop = asyncio.get_event_loop()
appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
config["appservice.as_token"], config["appservice.hs_token"],
config["appservice.bot_username"], log="mau.as", loop=loop)
@@ -83,6 +85,10 @@ appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
context = Context(appserv, db_session, config, loop, None, None)
if config["appservice.public.enabled"]:
public = PublicBridgeWebsite(loop)
appserv.app.add_subapp(config.get("appservice.public.prefix", "/public"), public.app)
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
init_db(db_session)
init_abstract_user(context)
+51 -21
View File
@@ -54,16 +54,58 @@ def register(evt):
async def login(evt):
if evt.sender.logged_in:
return await evt.reply("You are already logged in.")
elif len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp login <phone number>`")
phone_number = evt.args[0]
await evt.sender.ensure_started(even_if_no_session=True)
await evt.sender.client.sign_in(phone_number)
evt.sender.command_status = {
"next": enter_code,
"next": enter_phone,
"action": "Login",
}
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
if evt.config["appservice.public.enabled"]:
prefix = evt.config["appservice.public.external"]
url = f"{prefix}/login?mxid={evt.sender.mxid}"
if evt.config.get("bridge.allow_matrix_login", True):
return await evt.reply("\n\n".join((
"This bridge instance allows you to log in inside or outside Matrix.",
"If you would like to log in within Matrix, please send your phone number here.",
f"If you would like to log in outside of Matrix, [click here]({url}).")))
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.")
return await evt.reply(
"This bridge instance does not allow you to log in outside of Matrix.\n\n"
"Please send your phone number here to start the login process.")
@command_handler(needs_auth=False)
async def enter_phone(evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone <phone>`")
phone_number = evt.args[0]
try:
await evt.sender.ensure_started(even_if_no_session=True)
await evt.sender.client.sign_in(phone_number)
evt.sender.command_status = {
"next": enter_code,
"action": "Login",
}
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
except PhoneNumberAppSignupForbiddenError:
return await evt.reply(
"Your phone number does not allow 3rd party apps to sign in.")
except PhoneNumberFloodError:
return await evt.reply(
"Your phone number has been temporarily blocked for flooding. "
"The ban is usually applied for around a day.")
except PhoneNumberBannedError:
return await evt.reply("Your phone number has been banned from Telegram.")
except PhoneNumberUnoccupiedError:
return await evt.reply("That phone number has not been registered. "
"Please register with `$cmdprefix+sp register <phone>`.")
except Exception:
evt.log.exception("Error requesting phone code")
return await evt.reply("Unhandled exception while requesting code. "
"Check console for more details.")
finally:
if evt.sender.command_status["next"] == enter_phone:
evt.sender.command_status = None
@command_handler(needs_auth=False)
@@ -77,33 +119,21 @@ async def enter_code(evt):
asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
evt.sender.command_status = None
return await evt.reply(f"Successfully logged in as @{user.username}")
except PhoneNumberUnoccupiedError:
return await evt.reply("That phone number has not been registered."
"Please register with `$cmdprefix+sp register <phone>`.")
except PhoneCodeExpiredError:
return await evt.reply(
"Phone code expired. Try again with `$cmdprefix+sp login <phone>`.")
except PhoneCodeInvalidError:
return await evt.reply("Invalid phone code.")
except PhoneNumberAppSignupForbiddenError:
return await evt.reply(
"Your phone number does not allow 3rd party apps to sign in.")
except PhoneNumberFloodError:
return await evt.reply(
"Your phone number has been temporarily blocked for flooding. "
"The block is usually applied for around a day.")
except PhoneNumberBannedError:
return await evt.reply("Your phone number has been banned from Telegram.")
except SessionPasswordNeededError:
evt.sender.command_status = {
"next": enter_password,
"action": "Login (password entry)",
}
return await evt.reply("Your account has two-factor authentication."
return await evt.reply("Your account has two-factor authentication. "
"Please send your password here.")
except Exception:
evt.log.exception("Error sending phone code")
return await evt.reply("Unhandled exception while sending code."
return await evt.reply("Unhandled exception while sending code. "
"Check console for more details.")
+2 -18
View File
@@ -19,6 +19,8 @@ import logging
from telethon.errors import FloodWaitError
from ..util import format_duration
command_handlers = {}
@@ -67,24 +69,6 @@ class CommandEvent:
return self.az.intent.send_notice(self.room_id, message, html=html)
def format_duration(seconds):
def pluralize(count, singular): return singular if count == 1 else singular + "s"
def include(count, word): return f"{count} {pluralize(count, word)}" if count > 0 else ""
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
days, hours = divmod(hours, 24)
parts = [a for a in [
include(days, "day"),
include(hours, "hour"),
include(minutes, "minute"),
include(seconds, "second")] if a]
if len(parts) > 2:
return "{} and {}".format(", ".join(parts[:-1]), parts[-1])
return " and ".join(parts)
class CommandHandler:
log = logging.getLogger("mau.commands")
+3 -3
View File
@@ -50,9 +50,9 @@ def help(evt):
**cancel** - Cancel an ongoing action (such as login).
#### Authentication
**login** <_phone_> - Request an authentication code.
**logout** - Log out from Telegram.
**ping** - Check if you're logged into Telegram.
**login** - Request an authentication code.
**logout** - Log out from Telegram.
**ping** - Check if you're logged into Telegram.
#### Initiating chats
**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users.
+158
View File
@@ -0,0 +1,158 @@
# -*- 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 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from aiohttp import web
from mako.template import Template
import asyncio
import pkg_resources
import logging
from telethon.errors import *
from ..user import User
from ..commands.auth import enter_password
class PublicBridgeWebsite:
log = logging.getLogger("mau.public")
def __init__(self, loop):
self.loop = loop
self.login = Template(
pkg_resources.resource_string("mautrix_telegram", "public/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_static("/",
pkg_resources.resource_filename("mautrix_telegram", "public/"))
async def get_login(self, request):
user = (User.get_by_mxid(request.rel_url.query["mxid"], create=False)
if "mxid" in request.rel_url.query else None)
if not user:
return self.render_login(mxid=request.rel_url.query["mxid"], state="request")
elif not user.whitelisted:
return self.render_login(mxid=user.mxid, error="You are not whitelisted.", status=403)
await user.ensure_started()
if not user.logged_in:
return self.render_login(mxid=user.mxid, state="request")
return self.render_login(mxid=user.mxid, username=user.username)
def render_login(self, status=200, username="", state="", error="", message="", mxid=""):
return web.Response(status=status, content_type="text/html",
text=self.login.render(username=username, state=state, error=error,
message=message, mxid=mxid))
async def post_login_phone(self, user, phone):
try:
await user.client.sign_in(phone or "+123")
return self.render_login(mxid=user.mxid, state="code", status=200,
message="Code requested successfully.")
except PhoneNumberInvalidError:
return self.render_login(mxid=user.mxid, state="request", status=400,
error="Invalid phone number.")
except PhoneNumberUnoccupiedError:
return self.render_login(mxid=user.mxid, state="request", status=404,
error="That phone number has not been registered.")
except PhoneNumberFloodError:
return self.render_login(
mxid=user.mxid, state="request", status=429,
error="Your phone number has been temporarily banned for flooding. "
"The ban is usually applied for around a day.")
except PhoneNumberBannedError:
return self.render_login(mxid=user.mxid, state="request", status=401,
error="Your phone number is banned from Telegram.")
except PhoneNumberAppSignupForbiddenError:
return self.render_login(mxid=user.mxid, state="request", status=401,
error="You have disabled 3rd party apps on your account.")
except Exception:
self.log.exception("Error requesting phone code")
return self.render_login(mxid=user.mxid, state="request", status=500,
error="Internal server error while requesting code.")
async def post_login_code(self, user, code, password_in_data):
try:
user_info = await user.client.sign_in(code=code)
asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
if user.command_status and user.command_status.action == "Login":
user.command_status = None
return self.render_login(mxid=user.mxid, state="logged-in", status=200,
username=user_info.username)
except PhoneCodeInvalidError:
return self.render_login(mxid=user.mxid, state="code", status=403,
error="Incorrect phone code.")
except PhoneCodeExpiredError:
return self.render_login(mxid=user.mxid, state="code", status=403,
error="Phone code expired.")
except SessionPasswordNeededError:
if not password_in_data:
if user.command_status and user.command_status.action == "Login":
user.command_status = {
"next": enter_password,
"action": "Login (password entry)",
}
return self.render_login(
mxid=user.mxid, state="password", status=200,
message="Code accepted, but you have 2-factor authentication is enabled.")
return None
except Exception:
self.log.exception("Error sending phone code")
return self.render_login(mxid=user.mxid, state="code", status=500,
error="Internal server error while sending code.")
async def post_login_password(self, user, password):
try:
user_info = await user.client.sign_in(password=password)
asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
if user.command_status and user.command_status.action == "Login (password entry)":
user.command_status = None
return self.render_login(mxid=user.mxid, state="logged-in", status=200,
username=user_info.username)
except (PasswordHashInvalidError, PasswordEmptyError):
return self.render_login(mxid=user.mxid, state="password", status=400,
error="Incorrect password.")
except Exception:
self.log.exception("Error sending password")
return self.render_login(mxid=user.mxid, state="password", status=500,
error="Internal server error while sending password.")
async def post_login(self, request):
self.log.debug(request)
data = await request.post()
if "mxid" not in data:
return self.render_login(error="Please enter your Matrix ID.", status=400)
user = await User.get_by_mxid(data["mxid"]).ensure_started()
if not user.whitelisted:
return self.render_login(mxid=user.mxid, error="You are not whitelisted.", status=403)
elif user.logged_in:
return self.render_login(mxid=user.mxid, username=user.username)
if "phone" in data:
return await self.post_login_phone(user, data["phone"])
elif "code" in data:
resp = await self.post_login_code(user, data["code"], password_in_data="password" in data)
if resp:
return resp
elif "password" not in data:
return self.render_login(error="No data given.", status=400)
if "password" in data:
return await self.post_login_password(user, data["password"])
return self.render_login(error="This should never happen.", status=500)
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+33
View File
@@ -0,0 +1,33 @@
form > div {
display: none;
}
form[data-status="request"] > div.status-request,
form[data-status="code"] > div.status-code,
form[data-status="password"] > div.status-password {
display: initial;
}
.container {
margin-top: 3rem;
max-width: 60rem;
}
.error, .message {
border-radius: .25rem;
padding: .5rem 1rem;
border: 1px solid transparent;
margin: .5rem 0;
}
.error {
border-color: #f5c6cb;
background-color: #f8d7da;
color: #721c24;
}
.message {
border-color: #c3e6cb;
background-color: #d4edda;
color: #155724;
}
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html>
<head>
<title>Mautrix-Telegram bridge</title>
<link rel="icon" type="image/png" href="favicon.png"/>
<meta property="og:title" content="Mautrix-Telegram bridge">
<meta property="og:description" content="A hybrid puppeting/relaybot Matrix-Telegram bridge">
<meta property="og:image" content="favicon.png">
<meta charset="utf-8">
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:300,700">
<link rel="stylesheet" href="//cdn.rawgit.com/necolas/normalize.css/master/normalize.css">
<link rel="stylesheet"
href="//cdn.rawgit.com/milligram/milligram/master/dist/milligram.min.css">
<link rel="stylesheet" href="login.css"/>
</head>
<body>
<main class="container">
% if username:
% if state == "logged-in":
<h1>Logged in successfully!</h1>
<p>
Logged in as @${username}.
You can now close this page.
You should be invited to Telegram portals on Matrix momentarily.
</p>
% else:
<h1>You're already logged in!</h1>
<p>
You're logged in as @${username}.
</p>
<p>
If you want to log in with another account, log out using the <code>logout</code>
management command first.
</p>
% endif
% else:
<h1>Log in to Telegram</h1>
% if error:
<div class="error">${error}</div>
% endif
% if message:
<div class="message">${message}</div>
% endif
<form method="post">
<fieldset>
<label for="mxid">Matrix ID</label>
<input type="text" id="mxid" name="mxid" placeholder="Enter Matrix ID"
value="${mxid}"/>
% if state == "request":
<label for="value">Phone number</label>
<input type="tel" id="value" name="phone" placeholder="Enter phone number"/>
<button type="submit">Request code</button>
% elif state == "code":
<label for="value">Phone code</label>
<input type="number" id="value" name="code" placeholder="Enter phone code"/>
<button type="submit">Sign in</button>
<div class="float-right">
<button class="button-clear" type="button"
onclick="location.replace(location.href)">
Go back
</button>
</div>
% elif state == "password":
<label for="value">Password</label>
<input type="password" id="value" name="password"
placeholder="Enter password"/>
<button type="submit">Sign in</button>
<div class="float-right">
<button class="button-clear" type="button"
onclick="location.replace(location.href)">
Go back
</button>
</div>
% endif
</fieldset>
</form>
% endif
</main>
</body>
</html>
+1
View File
@@ -1 +1,2 @@
from .file_transfer import transfer_file_to_matrix
from .format_duration import format_duration
+34
View File
@@ -0,0 +1,34 @@
# -*- 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 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
def format_duration(seconds):
def pluralize(count, singular): return singular if count == 1 else singular + "s"
def include(count, word): return f"{count} {pluralize(count, word)}" if count > 0 else ""
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
days, hours = divmod(hours, 24)
parts = [a for a in [
include(days, "day"),
include(hours, "hour"),
include(minutes, "minute"),
include(seconds, "second")] if a]
if len(parts) > 2:
return "{} and {}".format(", ".join(parts[:-1]), parts[-1])
return " and ".join(parts)
+2
View File
@@ -44,4 +44,6 @@ setuptools.setup(
[console_scripts]
mautrix-telegram=mautrix_telegram.__main__:main
""",
package_data={"mautrix_telegram": ["public/*.html", "public/*.png", "public/*.css", "public/*.js"]},
data_files=[(".", "example-config.yaml")],
)