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'