You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@arrow.apache.org by ap...@apache.org on 2022/02/09 16:58:36 UTC
[arrow-cookbook] branch main updated: [Python][Flight] Add an 'HTTP basic auth' example (#123)
This is an automated email from the ASF dual-hosted git repository.
apitrou pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-cookbook.git
The following commit(s) were added to refs/heads/main by this push:
new 8d18441 [Python][Flight] Add an 'HTTP basic auth' example (#123)
8d18441 is described below
commit 8d1844191080063eca090da584ff437c63b0583d
Author: David Li <li...@gmail.com>
AuthorDate: Wed Feb 9 11:58:27 2022 -0500
[Python][Flight] Add an 'HTTP basic auth' example (#123)
---
python/source/flight.rst | 220 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 220 insertions(+)
diff --git a/python/source/flight.rst b/python/source/flight.rst
index d865902..f41e325 100644
--- a/python/source/flight.rst
+++ b/python/source/flight.rst
@@ -385,3 +385,223 @@ stream as it arrives, instead of reading them all into a table:
# Shutdown the server
server.shutdown()
repo.cleanup()
+
+Authentication with user/password
+=================================
+
+Often, services need a way to authenticate the user and identify who
+they are. Flight provides :doc:`several ways to implement
+authentication <pyarrow:format/Flight>`; the simplest uses a
+user-password scheme. At startup, the client authenticates itself with
+the server using a username and password. The server returns an
+authorization token to include on future requests.
+
+.. warning:: Authentication should only be used over a secure encrypted
+ channel, i.e. TLS should be enabled.
+
+.. note:: While the scheme is described as "`(HTTP) basic
+ authentication`_", it does not actually implement HTTP
+ authentication (RFC 7325) per se.
+
+While Flight provides some interfaces to implement such a scheme, the
+server must provide the actual implementation, as demonstrated
+below. **The implementation here is not secure and is provided as a
+minimal example only.**
+
+.. testcode::
+
+ import base64
+ import secrets
+
+ import pyarrow as pa
+ import pyarrow.flight
+
+
+ class EchoServer(pa.flight.FlightServerBase):
+ """A simple server that just echoes any requests from DoAction."""
+
+ def do_action(self, context, action):
+ return [action.type.encode("utf-8"), action.body]
+
+
+ class BasicAuthServerMiddlewareFactory(pa.flight.ServerMiddlewareFactory):
+ """
+ Middleware that implements username-password authentication.
+
+ Parameters
+ ----------
+ creds: Dict[str, str]
+ A dictionary of username-password values to accept.
+ """
+
+ def __init__(self, creds):
+ self.creds = creds
+ # Map generated bearer tokens to users
+ self.tokens = {}
+
+ def start_call(self, info, headers):
+ """Validate credentials at the start of every call."""
+ # Search for the authentication header (case-insensitive)
+ auth_header = None
+ for header in headers:
+ if header.lower() == "authorization":
+ auth_header = headers[header][0]
+ break
+
+ if not auth_header:
+ raise pa.flight.FlightUnauthenticatedError("No credentials supplied")
+
+ # The header has the structure "AuthType TokenValue", e.g.
+ # "Basic <encoded username+password>" or "Bearer <random token>".
+ auth_type, _, value = auth_header.partition(" ")
+
+ if auth_type == "Basic":
+ # Initial "login". The user provided a username/password
+ # combination encoded in the same way as HTTP Basic Auth.
+ decoded = base64.b64decode(value).decode("utf-8")
+ username, _, password = decoded.partition(':')
+ if not password or password != self.creds.get(username):
+ raise pa.flight.FlightUnauthenticatedError("Unknown user or invalid password")
+ # Generate a secret, random bearer token for future calls.
+ token = secrets.token_urlsafe(32)
+ self.tokens[token] = username
+ return BasicAuthServerMiddleware(token)
+ elif auth_type == "Bearer":
+ # An actual call. Validate the bearer token.
+ username = self.tokens.get(value)
+ if username is None:
+ raise pa.flight.FlightUnauthenticatedError("Invalid token")
+ return BasicAuthServerMiddleware(value)
+
+ raise pa.flight.FlightUnauthenticatedError("No credentials supplied")
+
+
+ class BasicAuthServerMiddleware(pa.flight.ServerMiddleware):
+ """Middleware that implements username-password authentication."""
+
+ def __init__(self, token):
+ self.token = token
+
+ def sending_headers(self):
+ """Return the authentication token to the client."""
+ return {"authorization": f"Bearer {self.token}"}
+
+
+ class NoOpAuthHandler(pa.flight.ServerAuthHandler):
+ """
+ A handler that implements username-password authentication.
+
+ This is required only so that the server will respond to the internal
+ Handshake RPC call, which the client calls when authenticate_basic_token
+ is called. Otherwise, it should be a no-op as the actual authentication is
+ implemented in middleware.
+ """
+
+ def authenticate(self, outgoing, incoming):
+ pass
+
+ def is_valid(self, token):
+ return ""
+
+We can then start the server:
+
+.. code-block::
+
+ if __name__ == '__main__':
+ server = EchoServer(
+ auth_handler=NoOpAuthHandler(),
+ location="grpc://0.0.0.0:8816",
+ middleware={
+ "basic": BasicAuthServerMiddlewareFactory({
+ "test": "password",
+ })
+ },
+ )
+ server.serve()
+
+.. testcode::
+ :hide:
+
+ # Code block to start for real a server in background
+ # and wait for it to be available.
+ # Previous code block is just to show to user how to start it.
+ import threading
+ server = EchoServer(
+ auth_handler=NoOpAuthHandler(),
+ location="grpc://0.0.0.0:8816",
+ middleware={
+ "basic": BasicAuthServerMiddlewareFactory({
+ "test": "password",
+ })
+ },
+ )
+ t = threading.Thread(target=server.serve)
+ t.start()
+
+Then, we can make a client and log in:
+
+.. testcode::
+
+ import pyarrow as pa
+ import pyarrow.flight
+
+ client = pa.flight.connect("grpc://0.0.0.0:8816")
+
+ token_pair = client.authenticate_basic_token(b'test', b'password')
+ print(token_pair)
+
+.. testoutput::
+
+ (b'authorization', b'Bearer ...')
+
+For future calls, we include the authentication token with the call:
+
+.. testcode::
+
+ action = pa.flight.Action("echo", b"Hello, world!")
+ options = pa.flight.FlightCallOptions(headers=[token_pair])
+ for response in client.do_action(action=action, options=options):
+ print(response.body.to_pybytes())
+
+.. testoutput::
+
+ b'echo'
+ b'Hello, world!'
+
+If we fail to do so, we get an authentication error:
+
+.. testcode::
+
+ try:
+ list(client.do_action(action=action))
+ except pa.flight.FlightUnauthenticatedError as e:
+ print("Unauthenticated:", e)
+ else:
+ raise RuntimeError("Expected call to fail")
+
+.. testoutput::
+
+ Unauthenticated: No credentials supplied. Detail: Unauthenticated
+
+Or if we use the wrong credentials on login, we also get an error:
+
+.. testcode::
+
+ try:
+ client.authenticate_basic_token(b'invalid', b'password')
+ except pa.flight.FlightUnauthenticatedError as e:
+ print("Unauthenticated:", e)
+ else:
+ raise RuntimeError("Expected call to fail")
+
+.. testoutput::
+
+ Unauthenticated: Unknown user or invalid password. Detail: Unauthenticated
+
+.. testcode::
+ :hide:
+
+ # Shutdown the server
+ server.shutdown()
+
+.. _(HTTP) basic authentication: https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#basic_authentication_scheme