Skip to content

Commit

Permalink
Add HTTP Basic Auth to asyncio & threading servers.
Browse files Browse the repository at this point in the history
  • Loading branch information
aaugustin committed Aug 21, 2024
1 parent 6b1cc94 commit e963e3c
Show file tree
Hide file tree
Showing 12 changed files with 678 additions and 28 deletions.
86 changes: 73 additions & 13 deletions docs/howto/upgrade.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,6 @@ Missing features
If your application relies on one of them, you should stick to the original
implementation until the new implementation supports it in a future release.

HTTP Basic Authentication
.........................

On the server side, :func:`~asyncio.server.serve` doesn't provide HTTP Basic
Authentication yet.

For the avoidance of doubt, on the client side, :func:`~asyncio.client.connect`
performs HTTP Basic Authentication.

Following redirects
...................

Expand Down Expand Up @@ -165,12 +156,12 @@ Server APIs
| ``websockets.broadcast`` |br| | :func:`websockets.asyncio.server.broadcast` |
| :func:`websockets.legacy.server.broadcast()` | |
+-------------------------------------------------------------------+-----------------------------------------------------+
| ``websockets.BasicAuthWebSocketServerProtocol`` |br| | *not available yet* |
| ``websockets.auth.BasicAuthWebSocketServerProtocol`` |br| | |
| ``websockets.BasicAuthWebSocketServerProtocol`` |br| | See below :ref:`how to migrate <basic-auth>` to |
| ``websockets.auth.BasicAuthWebSocketServerProtocol`` |br| | :func:`websockets.asyncio.server.basic_auth`. |
| :class:`websockets.legacy.auth.BasicAuthWebSocketServerProtocol` | |
+-------------------------------------------------------------------+-----------------------------------------------------+
| ``websockets.basic_auth_protocol_factory()`` |br| | *not available yet* |
| ``websockets.auth.basic_auth_protocol_factory()`` |br| | |
| ``websockets.basic_auth_protocol_factory()`` |br| | See below :ref:`how to migrate <basic-auth>` to |
| ``websockets.auth.basic_auth_protocol_factory()`` |br| | :func:`websockets.asyncio.server.basic_auth`. |
| :func:`websockets.legacy.auth.basic_auth_protocol_factory` | |
+-------------------------------------------------------------------+-----------------------------------------------------+

Expand Down Expand Up @@ -206,6 +197,75 @@ implementation.
Depending on your use case, adopting this method may improve performance when
streaming large messages. Specifically, it could reduce memory usage.

.. _basic-auth:

Performing HTTP Basic Authentication
....................................

.. admonition:: This section applies only to servers.
:class: tip

On the client side, :func:`~asyncio.client.connect` performs HTTP Basic
Authentication automatically when the URI contains credentials.

In the original implementation, the recommended way to add HTTP Basic
Authentication to a server was to set the ``create_protocol`` argument of
:func:`~legacy.server.serve` to a factory function generated by
:func:`~legacy.auth.basic_auth_protocol_factory`::

from websockets.legacy.auth import basic_auth_protocol_factory
from websockets.legacy.server import serve

async with serve(..., create_protocol=basic_auth_protocol_factory(...)):
...

In the new implementation, the :func:`~asyncio.server.basic_auth` function
generates a ``process_request`` coroutine that performs HTTP Basic
Authentication::

from websockets.asyncio.server import basic_auth, serve

async with serve(..., process_request=basic_auth(...)):
...

:func:`~asyncio.server.basic_auth` accepts either hard coded ``credentials`` or
a ``check_credentials`` coroutine as well as an optional ``realm`` just like
:func:`~legacy.auth.basic_auth_protocol_factory`. Furthermore,
``check_credentials`` may be a function instead of a coroutine.

This new API has more obvious semantics. That makes it easier to understand and
also easier to extend.

In the original implementation, overriding ``create_protocol`` changed the type
of connection objects to :class:`~legacy.auth.BasicAuthWebSocketServerProtocol`,
a subclass of :class:`~legacy.server.WebSocketServerProtocol` that performs HTTP
Basic Authentication in its ``process_request`` method. If you wanted to
customize ``process_request`` further, you had:

* an ill-defined option: add a ``process_request`` argument to
:func:`~legacy.server.serve`; to tell which one would run first, you had to
experiment or read the code;
* a cumbersome option: subclass
:class:`~legacy.auth.BasicAuthWebSocketServerProtocol`, then pass that
subclass in the ``create_protocol`` argument of
:func:`~legacy.auth.basic_auth_protocol_factory`.

In the new implementation, you just write a ``process_request`` coroutine::

from websockets.asyncio.server import basic_auth, serve

process_basic_auth = basic_auth(...)

async def process_request(connection, request):
... # some logic here
response = await process_basic_auth(connection, request)
if response is not None:
return response
... # more logic here

async with serve(..., process_request=process_request):
...

Customizing the opening handshake
.................................

Expand Down
6 changes: 6 additions & 0 deletions docs/project/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ notice.

*In development*

New features
............

* The new :mod:`asyncio` and :mod:`threading` implementations provide an API for
enforcing HTTP Basic Auth on the server side.

.. _13.0:

13.0
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/asyncio/server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,11 @@ Broadcast
---------

.. autofunction:: websockets.asyncio.server.broadcast

HTTP Basic Authentication
-------------------------

websockets supports HTTP Basic Authentication according to
:rfc:`7235` and :rfc:`7617`.

.. autofunction:: websockets.asyncio.server.basic_auth
2 changes: 1 addition & 1 deletion docs/reference/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Server
+------------------------------------+--------+--------+--------+--------+
| Force an HTTP response |||||
+------------------------------------+--------+--------+--------+--------+
| Perform HTTP Basic Authentication | | |||
| Perform HTTP Basic Authentication | | |||
+------------------------------------+--------+--------+--------+--------+
| Perform HTTP Digest Authentication |||||
+------------------------------------+--------+--------+--------+--------+
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/sync/server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,11 @@ Using a connection
.. autoattribute:: response

.. autoproperty:: subprotocol

HTTP Basic Authentication
-------------------------

websockets supports HTTP Basic Authentication according to
:rfc:`7235` and :rfc:`7617`.

.. autofunction:: websockets.sync.server.basic_auth
2 changes: 0 additions & 2 deletions docs/topics/compression.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ explicitly with :class:`ClientPerMessageDeflateFactory` or
compress_settings={"memLevel": 4},
),
],
...,
)

serve(
Expand All @@ -57,7 +56,6 @@ explicitly with :class:`ClientPerMessageDeflateFactory` or
compress_settings={"memLevel": 4},
),
],
...,
)

The Window Bits and Memory Level values in these examples reduce memory usage
Expand Down
4 changes: 3 additions & 1 deletion src/websockets/asyncio/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ class connect:
:func:`connect` may be used as an asynchronous context manager::
async with websockets.asyncio.client.connect(...) as websocket:
from websockets.asyncio.client import connect
async with connect(...) as websocket:
...
The connection is closed automatically when exiting the context.
Expand Down
148 changes: 142 additions & 6 deletions src/websockets/asyncio/server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import hmac
import http
import logging
import socket
Expand All @@ -13,13 +14,18 @@
Generator,
Iterable,
Sequence,
cast,
)

from websockets.frames import CloseCode

from ..exceptions import InvalidHeader
from ..extensions.base import ServerExtensionFactory
from ..extensions.permessage_deflate import enable_server_permessage_deflate
from ..headers import validate_subprotocols
from ..frames import CloseCode
from ..headers import (
build_www_authenticate_basic,
parse_authorization_basic,
validate_subprotocols,
)
from ..http11 import SERVER, Request, Response
from ..protocol import CONNECTING, Event
from ..server import ServerProtocol
Expand All @@ -28,7 +34,14 @@
from .connection import Connection, broadcast


__all__ = ["broadcast", "serve", "unix_serve", "ServerConnection", "Server"]
__all__ = [
"broadcast",
"serve",
"unix_serve",
"ServerConnection",
"Server",
"basic_auth",
]


class ServerConnection(Connection):
Expand Down Expand Up @@ -79,6 +92,7 @@ def __init__(
)
self.server = server
self.request_rcvd: asyncio.Future[None] = self.loop.create_future()
self.username: str # see basic_auth()

def respond(self, status: StatusLike, text: str) -> Response:
"""
Expand Down Expand Up @@ -548,19 +562,21 @@ class serve:
:class:`asyncio.Server`. Treat it as an asynchronous context manager to
ensure that the server will be closed::
from websockets.asyncio.server import serve
def handler(websocket):
...
# set this future to exit the server
stop = asyncio.get_running_loop().create_future()
async with websockets.asyncio.server.serve(handler, host, port):
async with serve(handler, host, port):
await stop
Alternatively, call :meth:`~Server.serve_forever` to serve requests and
cancel it to stop the server::
server = await websockets.asyncio.server.serve(handler, host, port)
server = await serve(handler, host, port)
await server.serve_forever()
Args:
Expand Down Expand Up @@ -822,3 +838,123 @@ def unix_serve(
"""
return serve(handler, unix=True, path=path, **kwargs)


def is_credentials(credentials: Any) -> bool:
try:
username, password = credentials
except (TypeError, ValueError):
return False
else:
return isinstance(username, str) and isinstance(password, str)


def basic_auth(
realm: str = "",
credentials: tuple[str, str] | Iterable[tuple[str, str]] | None = None,
check_credentials: Callable[[str, str], Awaitable[bool] | bool] | None = None,
) -> Callable[[ServerConnection, Request], Awaitable[Response | None]]:
"""
Factory for ``process_request`` to enforce HTTP Basic Authentication.
:func:`basic_auth` is designed to integrate with :func:`serve` as follows::
from websockets.asyncio.server import basic_auth, serve
async with serve(
...,
process_request=basic_auth(
realm="my dev server",
credentials=("hello", "iloveyou"),
),
):
If authentication succeeds, the connection's ``username`` attribute is set.
If it fails, the server responds with an HTTP 401 Unauthorized status.
One of ``credentials`` or ``check_credentials`` must be provided; not both.
Args:
realm: Scope of protection. It should contain only ASCII characters
because the encoding of non-ASCII characters is undefined. Refer to
section 2.2 of :rfc:`7235` for details.
credentials: Hard coded authorized credentials. It can be a
``(username, password)`` pair or a list of such pairs.
check_credentials: Function or coroutine that verifies credentials.
It receives ``username`` and ``password`` arguments and returns
whether they're valid.
Raises:
TypeError: If ``credentials`` or ``check_credentials`` is wrong.
"""
if (credentials is None) == (check_credentials is None):
raise TypeError("provide either credentials or check_credentials")

if credentials is not None:
if is_credentials(credentials):
credentials_list = [cast(tuple[str, str], credentials)]
elif isinstance(credentials, Iterable):
credentials_list = list(cast(Iterable[tuple[str, str]], credentials))
if not all(is_credentials(item) for item in credentials_list):
raise TypeError(f"invalid credentials argument: {credentials}")
else:
raise TypeError(f"invalid credentials argument: {credentials}")

credentials_dict = dict(credentials_list)

def check_credentials(username: str, password: str) -> bool:
try:
expected_password = credentials_dict[username]
except KeyError:
return False
return hmac.compare_digest(expected_password, password)

assert check_credentials is not None # help mypy

async def process_request(
connection: ServerConnection,
request: Request,
) -> Response | None:
"""
Perform HTTP Basic Authentication.
If it succeeds, set the connection's ``username`` attribute and return
:obj:`None`. If it fails, return an HTTP 401 Unauthorized responss.
"""
try:
authorization = request.headers["Authorization"]
except KeyError:
response = connection.respond(
http.HTTPStatus.UNAUTHORIZED,
"Missing credentials\n",
)
response.headers["WWW-Authenticate"] = build_www_authenticate_basic(realm)
return response

try:
username, password = parse_authorization_basic(authorization)
except InvalidHeader:
response = connection.respond(
http.HTTPStatus.UNAUTHORIZED,
"Unsupported credentials\n",
)
response.headers["WWW-Authenticate"] = build_www_authenticate_basic(realm)
return response

valid_credentials = check_credentials(username, password)
if isinstance(valid_credentials, Awaitable):
valid_credentials = await valid_credentials

if not valid_credentials:
response = connection.respond(
http.HTTPStatus.UNAUTHORIZED,
"Invalid credentials\n",
)
response.headers["WWW-Authenticate"] = build_www_authenticate_basic(realm)
return response

connection.username = username
return None

return process_request
4 changes: 3 additions & 1 deletion src/websockets/sync/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,9 @@ def connect(
:func:`connect` may be used as a context manager::
with websockets.sync.client.connect(...) as websocket:
from websockets.sync.client import connect
with connect(...) as websocket:
...
The connection is closed automatically when exiting the context.
Expand Down
Loading

0 comments on commit e963e3c

Please sign in to comment.