Skip to content

Commit

Permalink
Add support for SASL PLAIN authentication
Browse files Browse the repository at this point in the history
This implements a minimal state machine to do the SASL PLAIN
authentication dance.

It could have been done even simpler by sending all the commands and not
checking for server capabilities, but I don't think it's being a good
citizen.

It changes the ServerConnection api by adding a sasl_login argument, and
reusing the old password argument, since I couldn't think of a usecase
where both were needed. The SimpleIRCClient and SingleServerIRCBot can
also pass this new argument.

A new "login_failed" generated event is added and is sent in some of the
cases where the SASL login can fail. Note that there is no timeout on
the state machine, so if the server does not send any of the expected
commands, it will just stay active forever, potentially with the login
failing.

It was tested on libera.chat with the cobe bot and seems to work
reasonably well.

Fixes #195
  • Loading branch information
anisse committed Sep 7, 2022
1 parent 2f0d324 commit 42055a3
Show file tree
Hide file tree
Showing 2 changed files with 63 additions and 0 deletions.
62 changes: 62 additions & 0 deletions irc/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
.. [IRC specifications] http://www.irchelp.org/irchelp/rfc/
"""

import base64
import bisect
import re
import select
Expand Down Expand Up @@ -144,6 +145,7 @@ def connect(
username=None,
ircname=None,
connect_factory=connection.Factory(),
sasl_login=None,
):
"""Connect/reconnect to a server.
Expand All @@ -158,6 +160,8 @@ def connect(
* server_address - The remote host/port of the server
* connect_factory - A callable that takes the server address and
returns a connection (with a socket interface)
* sasl_login - A string used to toggle sasl plain login. Password needs
to be set as well, and will be used for SASL, not PASS login.
This function can be called to reconnect a closed connection.
Expand All @@ -182,6 +186,7 @@ def connect(
self.ircname = ircname or nickname
self.password = password
self.connect_factory = connect_factory
self.sasl_login = sasl_login
try:
self.socket = self.connect_factory(self.server_address)
except socket.error as ex:
Expand All @@ -190,12 +195,69 @@ def connect(
self.reactor._on_connect(self.socket)

# Log on...
if self.sasl_login and self.password:
for i in ["cap", "authenticate", "saslsuccess", "saslfail"]:
self.add_global_handler(i, self._sasl_state_machine, -42)
self.cap("LS")
self.nick(self.nickname)
self.user(self.username, self.ircname)
self._sasl_step = "ls"
return self
if self.password:
self.pass_(self.password)
self.nick(self.nickname)
self.user(self.username, self.ircname)
return self

def _sasl_state_machine(self, connection, event):
if self._sasl_step == "ls":
if (
event.type == "cap"
and len(event.arguments) > 1
and event.arguments[0] == "LS"
):
if 'sasl' in event.arguments[1].split():
self.cap("REQ", "sasl")
self._sasl_step = "req"
else:
event = Event(
"login_failed", event.target, ["server does not support sasl"]
)
self._handle_event(event)
self._sasl_end()
elif self._sasl_step == "req":
if event.type == "cap" and len(event.arguments) > 1:
if event.arguments[0] == "ACK" and 'sasl' in event.arguments:
self.send_items('AUTHENTICATE', 'PLAIN')
self._sasl_step = "auth-plain"
elif event.arguments[0] == "NAK":
event = Event(
"login_failed", event.target, ["server refused sasl protocol"]
)
self._handle_event(event)
self._sasl_end()
elif self._sasl_step == "auth-plain":
if event.type == "authenticate" and event.target == "+":
auth_string = base64.b64encode(
self.encode("\x00%s\x00%s" % (self.sasl_login, self.password))
).decode()
self.send_items('AUTHENTICATE', auth_string)
self._sasl_step = "auth-sent"
elif self._sasl_step == "auth-sent":
if event.type == "saslsuccess":
self._sasl_end()
elif event.type == "saslfail":
event = Event("login_failed", event.target, event.arguments)
self._handle_event(event)
self._sasl_end()

def _sasl_end(self):
self._sasl_step = ""
self.cap("END")
# SASL done, de-register handlers
for i in ["cap", "authenticate", "saslsuccess", "saslfail"]:
self.remove_global_handler(i, self._sasl_state_machine)

def reconnect(self):
"""
Reconnect with the last arguments passed to self.connect()
Expand Down
1 change: 1 addition & 0 deletions irc/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@
"disconnect",
"ctcp",
"ctcpreply",
"login_failed",
]

protocol = [
Expand Down

0 comments on commit 42055a3

Please sign in to comment.