Skip to content
This repository has been archived by the owner on Mar 2, 2024. It is now read-only.

Commit

Permalink
Add web-based login interface
Browse files Browse the repository at this point in the history
  • Loading branch information
tulir committed Feb 28, 2021
1 parent e8b91b3 commit 0a2f31d
Show file tree
Hide file tree
Showing 21 changed files with 711 additions and 56 deletions.
10 changes: 7 additions & 3 deletions mautrix_facebook/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#
# 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 asyncio
import logging

Expand Down Expand Up @@ -46,7 +47,7 @@ class MessengerBridge(Bridge):
db: Database
config: Config
matrix: MatrixHandler
public_website: PublicBridgeWebsite
public_website: Optional[PublicBridgeWebsite]
state_store: PgBridgeStateStore

periodic_reconnect_task: asyncio.Task
Expand All @@ -61,8 +62,11 @@ def prepare_db(self) -> None:

def prepare_bridge(self) -> None:
super().prepare_bridge()
self.public_website = PublicBridgeWebsite(self.config["appservice.public.shared_secret"])
self.az.app.add_subapp(self.config["appservice.public.prefix"], self.public_website.app)
if self.config["appservice.public.enabled"]:
self.public_website = PublicBridgeWebsite(self.config["appservice.public.shared_secret"])
self.az.app.add_subapp(self.config["appservice.public.prefix"], self.public_website.app)
else:
self.public_website = None

def prepare_stop(self) -> None:
self.periodic_reconnect_task.cancel()
Expand Down
104 changes: 72 additions & 32 deletions mautrix_facebook/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@
# 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/>.
import asyncio
import time

from yarl import URL

from mautrix.client import Client
from mautrix.errors import MForbidden
from mautrix.bridge.commands import HelpSection, command_handler
from mautrix.bridge import custom_puppet as cpu
from mautrix.util.signed_token import sign_token

from maufbapi import AndroidState, AndroidAPI
from maufbapi.http import TwoFactorRequired, OAuthException, IncorrectPassword
Expand All @@ -28,48 +32,60 @@

SECTION_AUTH = HelpSection("Authentication", 10, "")


async def check_approved_login(state: AndroidState, api: AndroidAPI, evt: CommandEvent) -> None:
while evt.sender.command_status and evt.sender.command_status["action"] == "Login":
await asyncio.sleep(5)
try:
was_approved = await api.check_approved_machine()
except Exception as e:
evt.log.exception("Error checking if login was approved from another device")
await evt.reply(f"Error checking if login was approved from another device: {e}")
break
if was_approved:
prev_cmd_status = evt.sender.command_status
evt.sender.command_status = None
try:
await api.login_approved()
except TwoFactorRequired:
await evt.reply("Login approved from another device, but Facebook decided that "
"you need to enter the 2FA code anyway.")
evt.sender.command_status = prev_cmd_status
return
await evt.sender.on_logged_in(state)
await evt.reply("Login successfully approved from another device")
break
web_unsupported = ("This instance of the Facebook bridge does not support "
"the web-based login interface")
alternative_web_login = ("Alternatively, you may use [the web-based login interface]({url}) "
"to prevent the bridge and homeserver from seeing your password")
forced_web_login = ("This instance of the Facebook bridge does not allow in-Matrix login. "
"Please use [the web-based login interface]({url}).")
send_password = "Please send your password here to log in"
missing_email = "Please use `$cmdprefix+sp login <email>` to log in here"


@command_handler(needs_auth=False, management_only=True,
help_section=SECTION_AUTH, help_text="Log in to Facebook",
help_args="<_email_> <_password_>")
@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH,
help_text="Log in to Facebook", help_args="[_email_]")
async def login(evt: CommandEvent) -> None:
if len(evt.args) < 2:
await evt.reply("Usage: `$cmdprefix+sp login <email> <password>`")
if evt.sender.client:
await evt.reply("You're already logged in")
return

email, password = evt.args[0], " ".join(evt.args[1:])
email = evt.args[0] if len(evt.args) > 0 else None

if email:
evt.sender.command_status = {
"action": "Login",
"room_id": evt.room_id,
"next": enter_password,
"email": evt.args[0],
}

if evt.bridge.public_website:
external_url = URL(evt.config["appservice.public.external"])
token = sign_token(evt.bridge.public_website.secret_key, {
"mxid": evt.sender.mxid,
"expiry": int(time.time()) + 30 * 60,
})
url = (external_url / "login.html").with_fragment(token)
if not evt.config["appservice.public.allow_matrix_login"]:
await evt.reply(forced_web_login.format(url=url))
elif email:
await evt.reply(f"{send_password}. {alternative_web_login.format(url=url)}.")
else:
await evt.reply(f"{missing_email}. {alternative_web_login.format(url=url)}.")
elif not email:
await evt.reply(f"{missing_email}. {web_unsupported}.")
else:
await evt.reply(f"{send_password}. {web_unsupported}.")


async def enter_password(evt: CommandEvent) -> None:
try:
await evt.az.intent.redact(evt.room_id, evt.event_id)
except MForbidden:
pass

if evt.sender.client:
await evt.reply("You're already logged in")
return
email = evt.sender.command_status["email"]
password = evt.content.body

state = AndroidState()
state.generate(evt.sender.mxid)
Expand Down Expand Up @@ -101,6 +117,30 @@ async def login(evt: CommandEvent) -> None:
await evt.reply(f"Failed to log in: {e}")


async def check_approved_login(state: AndroidState, api: AndroidAPI, evt: CommandEvent) -> None:
while evt.sender.command_status and evt.sender.command_status["action"] == "Login":
await asyncio.sleep(5)
try:
was_approved = await api.check_approved_machine()
except Exception as e:
evt.log.exception("Error checking if login was approved from another device")
await evt.reply(f"Error checking if login was approved from another device: {e}")
break
if was_approved:
prev_cmd_status = evt.sender.command_status
evt.sender.command_status = None
try:
await api.login_approved()
except TwoFactorRequired:
await evt.reply("Login approved from another device, but Facebook decided that "
"you need to enter the 2FA code anyway.")
evt.sender.command_status = prev_cmd_status
return
await evt.sender.on_logged_in(state)
await evt.reply("Login successfully approved from another device")
break


async def enter_2fa_code(evt: CommandEvent) -> None:
checker_task: asyncio.Task = evt.sender.command_status["checker_task"]
checker_task.cancel()
Expand Down
1 change: 1 addition & 0 deletions mautrix_facebook/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def do_update(self, helper: ConfigUpdateHelper) -> None:
base["appservice.public.shared_secret"] = self._new_token()
else:
copy("appservice.public.shared_secret")
copy("appservice.public.allow_matrix_login")

copy("metrics.enabled")
copy("metrics.listen_port")
Expand Down
2 changes: 2 additions & 0 deletions mautrix_facebook/example-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ appservice:
# If set to "generate", a random string will be generated on the next startup.
# If null, integration manager access to the API will not be possible.
shared_secret: generate
# Allow logging in within Matrix. If false, users can only log in using the web interface.
allow_matrix_login: true

# The unique ID of this appservice.
id: facebook
Expand Down
80 changes: 66 additions & 14 deletions mautrix_facebook/web/public.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import json

from aiohttp import web
import pkg_resources

from mautrix.types import UserID
from mautrix.util.signed_token import verify_token
Expand All @@ -30,6 +31,10 @@
from .. import user as u, puppet as pu


class InvalidTokenError(Exception):
pass


class PublicBridgeWebsite:
log: logging.Logger = logging.getLogger("mau.web.public")
app: web.Application
Expand All @@ -40,10 +45,11 @@ def __init__(self, shared_secret: str) -> None:
self.app = web.Application()
self.secret_key = "".join(random.choices(string.ascii_lowercase + string.digits, k=64))
self.shared_secret = shared_secret
for path in ("whoami", "login", "login/2fa", "login/check_approved", "login/approved",
"logout", "disconnect", "reconnect", "refresh"):
for path in ("whoami", "login", "login/prepare", "login/2fa", "login/check_approved",
"login/approved", "logout", "disconnect", "reconnect", "refresh"):
self.app.router.add_options(f"/api/{path}", self.login_options)
self.app.router.add_get("/api/whoami", self.status)
self.app.router.add_post("/api/login/prepare", self.login_prepare)
self.app.router.add_post("/api/login", self.login)
self.app.router.add_post("/api/login/2fa", self.login_2fa)
self.app.router.add_get("/api/login/check_approved", self.login_check_approved)
Expand All @@ -52,12 +58,16 @@ def __init__(self, shared_secret: str) -> None:
self.app.router.add_post("/api/disconnect", self.disconnect)
self.app.router.add_post("/api/reconnect", self.reconnect)
self.app.router.add_post("/api/refresh", self.refresh)
self.app.router.add_static("/", pkg_resources.resource_filename("mautrix_facebook.web",
"static/"))

def verify_token(self, token: str) -> Optional[UserID]:
def verify_token(self, token: str) -> UserID:
token = verify_token(self.secret_key, token)
if token and token.get("expiry", 0) > int(time.time()):
if token:
if token.get("expiry", 0) < int(time.time()):
raise InvalidTokenError("Access token has expired")
return UserID(token.get("mxid"))
return None
raise InvalidTokenError("Access token is invalid")

@property
def _acao_headers(self) -> Dict[str, str]:
Expand Down Expand Up @@ -94,9 +104,12 @@ async def check_token(self, request: web.Request) -> Optional['u.User']:
raise web.HTTPBadRequest(text='{"error": "Missing user_id query param"}',
headers=self._headers)
else:
user_id = self.verify_token(token)
if not user_id:
raise web.HTTPForbidden(text='{"error": "Invalid token"}', headers=self._headers)
try:
user_id = self.verify_token(token)
except InvalidTokenError as e:
raise web.HTTPForbidden(text=json.dumps({"error": f"{e}, please request a new one"
" from the bridge bot"}),
headers=self._headers)

user = await u.User.get_by_mxid(user_id)
return user
Expand All @@ -122,6 +135,28 @@ async def status(self, request: web.Request) -> web.Response:
f"{user.state.device.name}")
return web.json_response(data, headers=self._acao_headers)

async def login_prepare(self, request: web.Request) -> web.Response:
user = await self.check_token(request)
state = AndroidState()
state.generate(user.mxid)
api = AndroidAPI(state, log=user.log.getChild("login-api"))
user.command_status = {
"action": "Login",
"state": state,
"api": api,
}
try:
await api.mobile_config_sessionless()
except Exception as e:
self.log.exception("Failed to get mobile_config_sessionless to prepare login "
f"for {user.mxid}")
return web.json_response({"error": str(e)}, headers=self._acao_headers)
return web.json_response({
"status": "login",
"password_encryption_key_id": state.session.password_encryption_key_id,
"password_encryption_pubkey": state.session.password_encryption_pubkey
}, headers=self._acao_headers)

async def login(self, request: web.Request) -> web.Response:
user = await self.check_token(request)

Expand All @@ -132,16 +167,33 @@ async def login(self, request: web.Request) -> web.Response:

try:
email = data["email"]
except KeyError:
raise web.HTTPBadRequest(text='{"error": "Missing email"}', headers=self._headers)
try:
password = data["password"]
encrypted_password = None
except KeyError:
raise web.HTTPBadRequest(text='{"error": "Missing keys"}', headers=self._headers)
try:
encrypted_password = data["encrypted_password"]
password = None
except KeyError:
raise web.HTTPBadRequest(text='{"error": "Missing password"}',
headers=self._headers)

state = AndroidState()
state.generate(user.mxid)
api = AndroidAPI(state, log=user.log.getChild("login-api"))
try:
if encrypted_password:
if not user.command_status or user.command_status["action"] != "Login":
raise web.HTTPBadRequest(text='{"error": "No login in progress"}',
headers=self._headers)
state: AndroidState = user.command_status["state"]
api: AndroidAPI = user.command_status["api"]
else:
state = AndroidState()
state.generate(user.mxid)
api = AndroidAPI(state, log=user.log.getChild("login-api"))
await api.mobile_config_sessionless()
await api.login(email, password)

try:
await api.login(email, password=password, encrypted_password=encrypted_password)
await user.on_logged_in(state)
return web.json_response({"status": "logged-in"}, headers=self._acao_headers)
except TwoFactorRequired as e:
Expand Down
Loading

0 comments on commit 0a2f31d

Please sign in to comment.