This is an automated email from the ASF dual-hosted git repository.

kaxilnaik pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/master by this push:
     new 541c47c  Add basic auth API auth backend (#10356)
541c47c is described below

commit 541c47c99804bf09b5c775904e20580e48bb242f
Author: QP Hou <[email protected]>
AuthorDate: Wed Aug 19 01:44:17 2020 -0700

    Add basic auth API auth backend (#10356)
---
 airflow/api/auth/backend/basic_auth.py             |  65 ++++++++++
 airflow/api_connexion/exceptions.py                |  11 +-
 airflow/api_connexion/security.py                  |   6 +-
 docs/security/api.rst                              |  36 ++++++
 .../api/auth/backend/__init__.py                   |  21 ----
 tests/api/auth/backend/test_basic_auth.py          | 135 +++++++++++++++++++++
 6 files changed, 249 insertions(+), 25 deletions(-)

diff --git a/airflow/api/auth/backend/basic_auth.py 
b/airflow/api/auth/backend/basic_auth.py
new file mode 100644
index 0000000..bd42708
--- /dev/null
+++ b/airflow/api/auth/backend/basic_auth.py
@@ -0,0 +1,65 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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.
+"""Basic authentication backend"""
+from functools import wraps
+from typing import Callable, Optional, Tuple, TypeVar, Union, cast
+
+from flask import Response, current_app, request
+from flask_appbuilder.const import AUTH_LDAP
+from flask_appbuilder.security.sqla.models import User
+from flask_login import login_user
+from requests.auth import AuthBase
+
+CLIENT_AUTH: Optional[Union[Tuple[str, str], AuthBase]] = None
+
+
+def init_app(_):
+    """Initializes authentication backend"""
+
+
+T = TypeVar("T", bound=Callable)  # pylint: disable=invalid-name
+
+
+def auth_current_user() -> Optional[User]:
+    """Authenticate and set current user if Authorization header exists"""
+    auth = request.authorization
+    if auth is None or not auth.username or not auth.password:
+        return None
+
+    ab_security_manager = current_app.appbuilder.sm
+    user = None
+    if ab_security_manager.auth_type == AUTH_LDAP:
+        user = ab_security_manager.auth_user_ldap(auth.username, auth.password)
+    if user is None:
+        user = ab_security_manager.auth_user_db(auth.username, auth.password)
+    if user is not None:
+        login_user(user, remember=False)
+    return user
+
+
+def requires_authentication(function: T):
+    """Decorator for functions that require authentication"""
+    @wraps(function)
+    def decorated(*args, **kwargs):
+        if auth_current_user() is not None:
+            return function(*args, **kwargs)
+        else:
+            return Response(
+                "Unauthorized", 401, {"WWW-Authenticate": "Basic"}
+            )
+
+    return cast(T, decorated)
diff --git a/airflow/api_connexion/exceptions.py 
b/airflow/api_connexion/exceptions.py
index 7abeea6..e883727 100644
--- a/airflow/api_connexion/exceptions.py
+++ b/airflow/api_connexion/exceptions.py
@@ -14,6 +14,8 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from typing import Dict, Optional
+
 from connexion import ProblemException
 
 
@@ -31,8 +33,13 @@ class BadRequest(ProblemException):
 
 class Unauthenticated(ProblemException):
     """Raise when the user is not authenticated"""
-    def __init__(self, title='Unauthorized', detail=None):
-        super().__init__(status=401, title=title, detail=detail)
+    def __init__(
+        self,
+        title: str = 'Unauthorized',
+        detail: Optional[str] = None,
+        headers: Optional[Dict] = None,
+    ):
+        super().__init__(status=401, title=title, detail=detail, 
headers=headers)
 
 
 class PermissionDenied(ProblemException):
diff --git a/airflow/api_connexion/security.py 
b/airflow/api_connexion/security.py
index e4281d4..5ededfc 100644
--- a/airflow/api_connexion/security.py
+++ b/airflow/api_connexion/security.py
@@ -29,9 +29,11 @@ def requires_authentication(function: T):
     """Decorator for functions that require authentication"""
     @wraps(function)
     def decorated(*args, **kwargs):
-        response = current_app.api_auth.requires_authentication(lambda: 
Response(status=200))()
+        response = current_app.api_auth.requires_authentication(Response)()
         if response.status_code != 200:
-            raise Unauthenticated()
+            # since this handler only checks authentication, not authorization,
+            # we should always return 401
+            raise Unauthenticated(headers=response.headers)
         return function(*args, **kwargs)
 
     return cast(T, decorated)
diff --git a/docs/security/api.rst b/docs/security/api.rst
index 456b91f..bcbb30d 100644
--- a/docs/security/api.rst
+++ b/docs/security/api.rst
@@ -117,6 +117,42 @@ look like the following.
           -H 'Cache-Control: no-cache' \
           -H "Authorization: Bearer ${ID_TOKEN}"
 
+Basic authentication
+''''''''''''''''''''
+
+`Basic username password authentication <https://tools.ietf.org/html/rfc7617
+https://en.wikipedia.org/wiki/Basic_access_authentication>`_ is currently
+supported for the API. This works for users created through LDAP login or
+within Airflow Metadata DB using password.
+
+To enable basic authentication, set the following in the configuration:
+
+.. code-block:: ini
+
+    [api]
+    auth_backend = airflow.api.auth.backend.basic_auth
+
+Username and password needs to be base64 encoded and send through the
+``Authorization`` HTTP header in the following format:
+
+.. code-block:: text
+
+    Authorization: Basic Base64(username:password)
+
+Here is a sample curl command you can use to validate the setup:
+
+.. code-block:: bash
+
+    ENDPOINT_URL="http://locahost:8080/";
+    curl -X GET  \
+        --user "username:password" \
+        "${ENDPOINT_URL}/api/v1/pools"
+
+Note, you can still enable this setting to allow API access through username
+password credential even though Airflow webserver might be using another
+authentication method. Under this setup, only users created through LDAP or
+``airflow users create`` command will be able to pass the API authentication.
+
 Roll your own API authentication
 ''''''''''''''''''''''''''''''''
 
diff --git a/airflow/api_connexion/security.py 
b/tests/api/auth/backend/__init__.py
similarity index 54%
copy from airflow/api_connexion/security.py
copy to tests/api/auth/backend/__init__.py
index e4281d4..13a8339 100644
--- a/airflow/api_connexion/security.py
+++ b/tests/api/auth/backend/__init__.py
@@ -14,24 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
-from functools import wraps
-from typing import Callable, TypeVar, cast
-
-from flask import Response, current_app
-
-from airflow.api_connexion.exceptions import Unauthenticated
-
-T = TypeVar("T", bound=Callable)  # pylint: disable=invalid-name
-
-
-def requires_authentication(function: T):
-    """Decorator for functions that require authentication"""
-    @wraps(function)
-    def decorated(*args, **kwargs):
-        response = current_app.api_auth.requires_authentication(lambda: 
Response(status=200))()
-        if response.status_code != 200:
-            raise Unauthenticated()
-        return function(*args, **kwargs)
-
-    return cast(T, decorated)
diff --git a/tests/api/auth/backend/test_basic_auth.py 
b/tests/api/auth/backend/test_basic_auth.py
new file mode 100644
index 0000000..9a2a4a7
--- /dev/null
+++ b/tests/api/auth/backend/test_basic_auth.py
@@ -0,0 +1,135 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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 unittest
+from base64 import b64encode
+
+from flask_login import current_user
+from parameterized import parameterized
+
+from airflow.www.app import create_app
+from tests.test_utils.config import conf_vars
+from tests.test_utils.db import clear_db_pools
+
+
+class TestBasicAuth(unittest.TestCase):
+    def setUp(self) -> None:
+        with conf_vars(
+            {("api", "auth_backend"): "airflow.api.auth.backend.basic_auth"}
+        ):
+            self.app = create_app(testing=True)
+
+        self.appbuilder = self.app.appbuilder  # pylint: disable=no-member
+        role_admin = self.appbuilder.sm.find_role("Admin")
+        tester = self.appbuilder.sm.find_user(username="test")
+        if not tester:
+            self.appbuilder.sm.add_user(
+                username="test",
+                first_name="test",
+                last_name="test",
+                email="[email protected]",
+                role=role_admin,
+                password="test",
+            )
+
+    def test_success(self):
+        token = "Basic " + b64encode(b"test:test").decode()
+        clear_db_pools()
+
+        with self.app.test_client() as test_client:
+            response = test_client.get(
+                "/api/v1/pools", headers={"Authorization": token}
+            )
+            assert current_user.email == "[email protected]"
+
+        assert response.status_code == 200
+        assert response.json == {
+            "pools": [
+                {
+                    "name": "default_pool",
+                    "slots": 128,
+                    "occupied_slots": 0,
+                    "running_slots": 0,
+                    "queued_slots": 0,
+                    "open_slots": 128,
+                },
+            ],
+            "total_entries": 1,
+        }
+
+    @parameterized.expand([
+        ("basic",),
+        ("basic ",),
+        ("bearer",),
+        ("test:test",),
+        (b64encode(b"test:test").decode(),),
+        ("bearer ",),
+        ("basic: ",),
+        ("basic 123",),
+    ])
+    def test_malformed_headers(self, token):
+        with self.app.test_client() as test_client:
+            response = test_client.get(
+                "/api/v1/pools", headers={"Authorization": token}
+            )
+            assert response.status_code == 401
+            assert response.headers["Content-Type"] == 
"application/problem+json"
+            assert response.headers["WWW-Authenticate"] == "Basic"
+            assert response.json == {
+                'detail': None,
+                'status': 401,
+                'title': 'Unauthorized',
+                'type': 'about:blank',
+            }
+
+    @parameterized.expand([
+        ("basic " + b64encode(b"test").decode(),),
+        ("basic " + b64encode(b"test:").decode(),),
+        ("basic " + b64encode(b"test:123").decode(),),
+        ("basic " + b64encode(b"test test").decode(),),
+    ])
+    def test_invalid_auth_header(self, token):
+        with self.app.test_client() as test_client:
+            response = test_client.get(
+                "/api/v1/pools", headers={"Authorization": token}
+            )
+            assert response.status_code == 401
+            assert response.headers["Content-Type"] == 
"application/problem+json"
+            assert response.headers["WWW-Authenticate"] == "Basic"
+            assert response.json == {
+                'detail': None,
+                'status': 401,
+                'title': 'Unauthorized',
+                'type': 'about:blank',
+            }
+
+    def test_experimental_api(self):
+        with self.app.test_client() as test_client:
+            response = test_client.get(
+                "/api/experimental/pools", headers={"Authorization": "Basic"}
+            )
+            assert response.status_code == 401
+            assert response.headers["WWW-Authenticate"] == "Basic"
+            assert response.data == b'Unauthorized'
+
+            clear_db_pools()
+            response = test_client.get(
+                "/api/experimental/pools",
+                headers={"Authorization": "Basic " + 
b64encode(b"test:test").decode()}
+            )
+            assert response.status_code == 200
+            assert response.json[0]["pool"] == 'default_pool'

Reply via email to