Add initial out-of-Matrix login system

This commit is contained in:
Tulir Asokan
2018-02-18 19:44:32 +02:00
parent 7d2af0ce75
commit ad11abb56e
11 changed files with 270 additions and 42 deletions
+12
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
-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
+10 -5
View File
@@ -32,6 +32,7 @@ from .db import init as init_db
from .user import init as init_user, User
from .portal import init as init_portal
from .puppet import init as init_puppet
from .public import PublicBridgeWebsite
log = logging.getLogger("mau")
time_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s@%(name)s] %(message)s")
@@ -73,12 +74,16 @@ 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)
context = (appserv, db_session, config, loop)
az = AppService(config["homeserver.address"], config["homeserver.domain"],
config["appservice.as_token"], config["appservice.hs_token"],
config["appservice.bot_username"], log="mau.as", loop=loop)
context = (az, db_session, config, loop)
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
if config["appservice.public.enabled"]:
public = PublicBridgeWebsite(loop)
az.app.add_subapp(config.get("appservice.public.prefix", "/public"), public.app)
with az.run(config["appservice.hostname"], config["appservice.port"]) as start:
MatrixHandler(context)
init_db(db_session)
init_portal(context)
+23 -18
View File
@@ -44,12 +44,26 @@ async def login(evt):
elif len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp login <phone number>`")
phone_number = evt.args[0]
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.")
try:
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 Exception:
evt.log.exception("Error requesting phone code")
return await evt.reply("Unhandled exception while requesting code. "
"Check console for more details.")
@command_handler(needs_auth=False)
@@ -63,32 +77,23 @@ async def enter_code(evt):
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."
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 = {}
@@ -65,24 +67,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")
+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)
+137
View File
@@ -0,0 +1,137 @@
# -*- 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):
return self.render_login(
request.rel_url.query["mxid"] if "mxid" in request.rel_url.query else "")
def render_login(self, mxid, state="request", phone="", code="", password="",
error="", message="", username="", status=200):
return web.Response(status=status,
content_type="text/html",
text=self.login.render(mxid=mxid, state=state, phone=phone, code=code,
message=message, username=username, error=error,
password=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 = User.get_by_mxid(data["mxid"])
if not user.whitelisted:
return self.render_login(mxid=user.mxid, error="You are not whitelisted.", status=403)
if "phone" in data:
try:
await user.client.sign_in(data["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.")
elif "code" in data:
try:
user_info = await user.client.sign_in(code=data["code"])
asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
if 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 "password" not in data:
if 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,
error="Code accepted, but you have 2-factor authentication is enabled.")
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.")
elif "password" not in data:
return self.render_login(error="No data given.", status=400)
if "password" in data:
try:
user_info = await user.client.sign_in(password=data["password"])
asyncio.ensure_future(user.post_login(user_info), loop=self.loop)
if 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.")
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+9
View File
@@ -0,0 +1,9 @@
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;
}
+41
View File
@@ -0,0 +1,41 @@
<!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="login.css"/>
</head>
<body>
<main>
% if state == "logged-in":
<h1>Logged in successfully!</h1>
<p>Logged in as @${username}</p>
% 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">
<input type="text" name="mxid" placeholder="Enter Matrix ID" value="${mxid}"/>
% if state == "request":
<input type="text" name="phone" placeholder="Enter phone number"/>
<button type="submit">Request code</button>
% elif state == "code":
<input type="number" name="code" placeholder="Enter phone code"/>
<button type="submit">Sign in</button>
% elif state == "password":
<input type="password" name="password" placeholder="Enter password"/>
<button type="submit">Sign in</button>
% endif
</form>
% endif
</main>
</body>
</html>
+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")],
)