weechat-matrix: Add support for single sign-on.

This patch adds support for the single sign-on login flow. If no
user/password is set Weechat will do a SSO login attempt.
This commit is contained in:
Damir Jelić 2019-09-10 15:28:55 +02:00
parent cc0ccd6dba
commit 3080538549
3 changed files with 247 additions and 15 deletions

102
contrib/matrix_sso_helper Executable file
View file

@ -0,0 +1,102 @@
#!/usr/bin/env -S python3 -u
# Copyright 2019 The Matrix.org Foundation CIC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import socket
import json
from random import choice
from aiohttp import web
# The browsers ban some known ports, the dynamic port range doesn't contain any
# banned ports, so we use that.
port_range = range(49152, 65535)
shutdown_task = None
def to_weechat(message):
print(json.dumps(message))
async def get_token(request):
global shutdown_task
async def shutdown():
await asyncio.sleep(1)
raise KeyboardInterrupt
token = request.query.get("loginToken")
if not token:
raise KeyboardInterrupt
message = {
"type": "token",
"loginToken": token
}
# Send the token to weechat.
print(json.dumps(message))
# Initiate a shutdown.
shutdown_task = asyncio.ensure_future(shutdown())
# Respond to the browser.
return web.Response(text="Continuing in Weechat.")
def bind_socket():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
while True:
port = choice(port_range)
try:
sock.bind(("localhost", port))
except OSError:
continue
return sock
async def wait_for_shutdown_task(_):
if not shutdown_task:
return
try:
await shutdown_task
except KeyboardInterrupt:
pass
def main():
app = web.Application()
app.add_routes([web.get('/', get_token)])
sock = bind_socket()
host, port = sock.getsockname()
message = {
"type": "redirectUrl",
"host": host,
"port": port
}
to_weechat(message)
app.on_shutdown.append(wait_for_shutdown_task)
web.run_app(app, sock=sock, handle_signals=True, print=None)
if __name__ == "__main__":
main()

69
main.py
View file

@ -39,9 +39,12 @@ from itertools import chain
from typing import Any, AnyStr, Deque, Dict, List, Optional, Set, Text, Tuple from typing import Any, AnyStr, Deque, Dict, List, Optional, Set, Text, Tuple
import logbook import logbook
import json
import OpenSSL.crypto as crypto import OpenSSL.crypto as crypto
from future.utils import bytes_to_native_str as n from future.utils import bytes_to_native_str as n
from logbook import Logger, StreamHandler from logbook import Logger, StreamHandler
from json.decoder import JSONDecodeError
from nio import RemoteProtocolError, RemoteTransportError, TransportType from nio import RemoteProtocolError, RemoteTransportError, TransportType
from matrix import globals as G from matrix import globals as G
@ -91,6 +94,11 @@ from matrix.utils import server_buffer_prnt, server_buffer_set_title
from matrix.uploads import UploadsBuffer, upload_cb from matrix.uploads import UploadsBuffer, upload_cb
try:
from urllib.parse import urlunparse
except ImportError:
from urlparse import urlunparse
# yapf: disable # yapf: disable
WEECHAT_SCRIPT_NAME = SCRIPT_NAME WEECHAT_SCRIPT_NAME = SCRIPT_NAME
WEECHAT_SCRIPT_DESCRIPTION = "matrix chat plugin" # type: str WEECHAT_SCRIPT_DESCRIPTION = "matrix chat plugin" # type: str
@ -358,7 +366,66 @@ def finalize_connection(server):
data = server.client.connect(server.transport_type) data = server.client.connect(server.transport_type)
server.send(data) server.send(data)
server.login() server.login_info()
@utf8_decode
def sso_login_cb(server_name, command, return_code, out, err):
try:
server = SERVERS[server_name]
except KeyError:
message = (
"{}{}: SSO callback ran, but no server for it was found.").format(
W.prefix("error"), SCRIPT_NAME)
W.prnt("", message)
if return_code == W.WEECHAT_HOOK_PROCESS_ERROR:
server.error("Error while running the matrix_sso_helper. Please "
"make sure that the helper script is executable and can "
"be found in your PATH.")
server.sso_hook = None
server.disconnect()
return W.WEECHAT_RC_OK
# The child process exited mark the hook as done.
if return_code == 0:
server.sso_hook = None
if err != "":
W.prnt("", "stderr: %s" % err)
if out == "":
return W.WEECHAT_RC_OK
try:
ret = json.loads(out)
msgtype = ret.get("type")
if msgtype == "redirectUrl":
redirect_url = "http://{}:{}".format(ret["host"], ret["port"])
server.info_highlight(
"The server requested a single sign-on, please open "
"this URL in your browser. Note that the "
"browser needs to run on the same host as Weechat.")
server.info_highlight(
"{}/_matrix/client/r0/login/sso/redirect?redirectUrl={}".format(
server.homeserver.geturl(), redirect_url))
elif msgtype == "token":
token = ret["loginToken"]
server.login(token=token)
else:
server.error("Unknown SSO login message received from child "
"process.")
except JSONDecodeError:
server.error(
"Error decoding SSO login message from child process: {}".format(
out
))
return W.WEECHAT_RC_OK
@utf8_decode @utf8_decode

View file

@ -42,6 +42,7 @@ from nio import (
HttpClient, HttpClient,
LocalProtocolError, LocalProtocolError,
LoginResponse, LoginResponse,
LoginInfoResponse,
Response, Response,
Rooms, Rooms,
RoomSendResponse, RoomSendResponse,
@ -261,6 +262,8 @@ class MatrixServer(object):
self.ssl_context = ssl.create_default_context() # type: ssl.SSLContext self.ssl_context = ssl.create_default_context() # type: ssl.SSLContext
self.transport_type = None # type: Optional[TransportType] self.transport_type = None # type: Optional[TransportType]
self.sso_hook = None
# Enable http2 negotiation on the ssl context. # Enable http2 negotiation on the ssl context.
self.ssl_context.set_alpn_protocols(["h2", "http/1.1"]) self.ssl_context.set_alpn_protocols(["h2", "http/1.1"])
@ -304,6 +307,10 @@ class MatrixServer(object):
self.ignore_while_sharing = defaultdict(bool) self.ignore_while_sharing = defaultdict(bool)
self.to_device_sent = [] self.to_device_sent = []
# Try to load the device id, the device id is loaded every time the
# user changes but some login flows don't use a user so try to load the
# device for a main user.
self._load_device_id("main")
self.config = ServerConfig(self.name, config_ptr) self.config = ServerConfig(self.name, config_ptr)
self._create_session_dir() self._create_session_dir()
# yapf: enable # yapf: enable
@ -330,8 +337,10 @@ class MatrixServer(object):
home_dir = W.info_get("weechat_dir", "") home_dir = W.info_get("weechat_dir", "")
return os.path.join(home_dir, "matrix", self.name) return os.path.join(home_dir, "matrix", self.name)
def _load_device_id(self): def _load_device_id(self, user=None):
file_name = "{}{}".format(self.config.username, ".device_id") user = user or self.config.username
file_name = "{}{}".format(user, ".device_id")
path = os.path.join(self.get_session_path(), file_name) path = os.path.join(self.get_session_path(), file_name)
if not os.path.isfile(path): if not os.path.isfile(path):
@ -343,7 +352,7 @@ class MatrixServer(object):
self.device_id = device_id self.device_id = device_id
def save_device_id(self): def save_device_id(self):
file_name = "{}{}".format(self.config.username, ".device_id") file_name = "{}{}".format(self.config.username or "main", ".device_id")
path = os.path.join(self.get_session_path(), file_name) path = os.path.join(self.get_session_path(), file_name)
with atomic_write(path, overwrite=True) as device_file: with atomic_write(path, overwrite=True) as device_file:
@ -700,13 +709,6 @@ class MatrixServer(object):
W.prnt("", message) W.prnt("", message)
return False return False
if not self.config.username or not self.config.password:
message = "{prefix}User or password not set".format(
prefix=W.prefix("error")
)
W.prnt("", message)
return False
if self.connected: if self.connected:
return True return True
@ -756,11 +758,41 @@ class MatrixServer(object):
_, request = self.client.sync(timeout, sync_filter) _, request = self.client.sync(timeout, sync_filter)
self.send_or_queue(request) self.send_or_queue(request)
def login(self): def login_info(self):
# type: () -> None # type: () -> None
if not self.client: if not self.client:
return return
if self.client.logged_in:
self.login()
_, request = self.client.login_info()
self.send(request)
"""Start a local HTTP server to listen for SSO tokens."""
def start_login_sso(self):
# type: () -> None
if self.sso_hook:
# If there is a stale SSO process hanging around kill it. We could
# let it stay around but the URL that needs to be opened by the
# user is printed out in the callback.
W.hook_set(self.sso_hook, "signal", "term")
self.sso_hook = None
process_args = {
"buffer_flush": "1",
}
self.sso_hook = W.hook_process_hashtable(
"matrix_sso_helper",
process_args,
0,
"sso_login_cb",
self.name
)
def login(self, token=None):
# type: () -> None
if self.client.logged_in: if self.client.logged_in:
msg = ( msg = (
"{prefix}{script_name}: Already logged in, " "syncing..." "{prefix}{script_name}: Already logged in, " "syncing..."
@ -776,9 +808,21 @@ class MatrixServer(object):
self.sync(timeout, sync_filter) self.sync(timeout, sync_filter)
return return
_, request = self.client.login( if (not self.config.username or not self.config.password) and not token:
self.config.password, self.config.device_name message = "{prefix}User or password not set".format(
) prefix=W.prefix("error")
)
W.prnt("", message)
return self.disconnect()
if token:
_, request = self.client.login(
device_name=self.config.device_name, token=token
)
else:
_, request = self.client.login(
password=self.config.password, device_name=self.config.device_name
)
self.send_or_queue(request) self.send_or_queue(request)
msg = "{prefix}matrix: Logging in...".format( msg = "{prefix}matrix: Logging in...".format(
@ -1217,6 +1261,22 @@ class MatrixServer(object):
lines.append(line) lines.append(line)
W.prnt(self.server_buffer, "\n".join(lines)) W.prnt(self.server_buffer, "\n".join(lines))
"""Handle a login info response and chose one of the available flows
This currently supports only SSO and password logins. If both are available
password takes precedence over SSO if a username and password is provided.
"""
def _handle_login_info(self, response):
if ("m.login.sso" in response.flows
and (not self.config.username or self.config.password)):
self.start_login_sso()
elif "m.login.password" in response.flows:
self.login()
else:
self.error("No supported login flow found")
self.disconnect()
def _handle_login(self, response): def _handle_login(self, response):
self.access_token = response.access_token self.access_token = response.access_token
self.user_id = response.user_id self.user_id = response.user_id
@ -1504,6 +1564,9 @@ class MatrixServer(object):
elif isinstance(response, LoginResponse): elif isinstance(response, LoginResponse):
self._handle_login(response) self._handle_login(response)
elif isinstance(response, LoginInfoResponse):
self._handle_login_info(response)
elif isinstance(response, (SyncResponse, PartialSyncResponse)): elif isinstance(response, (SyncResponse, PartialSyncResponse)):
self._handle_sync(response) self._handle_sync(response)