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