diff --git a/airflow/api/auth/backend/basic_auth.py b/airflow/api/auth/backend/basic_auth.py index 6d4b507a0345f..6f215b8c1c1f5 100644 --- a/airflow/api/auth/backend/basic_auth.py +++ b/airflow/api/auth/backend/basic_auth.py @@ -14,56 +14,38 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -"""Basic authentication backend.""" -from __future__ import annotations +""" +This module is deprecated. -from functools import wraps -from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast +Please use :mod:`airflow.auth.managers.fab.api.auth.backend.basic_auth` instead. +""" +from __future__ import annotations -from flask import Response, request -from flask_appbuilder.const import AUTH_LDAP -from flask_login import login_user +import warnings +from typing import TYPE_CHECKING, Any, Callable -from airflow.utils.airflow_flask_app import get_airflow_app +import airflow.auth.managers.fab.api.auth.backend.basic_auth as fab_basic_auth +from airflow.exceptions import RemovedInAirflow3Warning if TYPE_CHECKING: from airflow.auth.managers.fab.models import User CLIENT_AUTH: tuple[str, str] | Any | None = None - -def init_app(_): - """Initialize authentication backend.""" +warnings.warn( + "This module is deprecated. Please use `airflow.auth.managers.fab.api.auth.backend.basic_auth` instead.", + RemovedInAirflow3Warning, + stacklevel=2, +) -T = TypeVar("T", bound=Callable) +def init_app(_): + fab_basic_auth.init_app(_) def auth_current_user() -> User | None: - """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 = get_airflow_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): - """Decorate functions that require authentication.""" + return fab_basic_auth.auth_current_user() - @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) +def requires_authentication(function: Callable): + return fab_basic_auth.requires_authentication(function) diff --git a/airflow/api/auth/backend/kerberos_auth.py b/airflow/api/auth/backend/kerberos_auth.py index e5458d05529c1..c632bd0dfe75c 100644 --- a/airflow/api/auth/backend/kerberos_auth.py +++ b/airflow/api/auth/backend/kerberos_auth.py @@ -16,6 +16,9 @@ # under the License. from __future__ import annotations +import warnings + +from airflow.exceptions import RemovedInAirflow3Warning from airflow.utils.airflow_flask_app import get_airflow_app # @@ -46,7 +49,7 @@ import logging import os from functools import wraps -from typing import Any, Callable, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast import kerberos from flask import Response, _request_ctx_stack as stack, g, make_response, request # type: ignore @@ -55,6 +58,9 @@ from airflow.configuration import conf from airflow.utils.net import getfqdn +if TYPE_CHECKING: + from airflow.auth.managers.models.base_user import BaseUser + log = logging.getLogger(__name__) @@ -129,8 +135,16 @@ def _gssapi_authenticate(token): T = TypeVar("T", bound=Callable) -def requires_authentication(function: T): +def requires_authentication(function: T, find_user: Callable[[str], BaseUser] | None = None): """Decorate functions that require authentication with Kerberos.""" + if not find_user: + warnings.warn( + "This module is deprecated. Please use " + "`airflow.auth.managers.fab.api.auth.backend.kerberos_auth` instead.", + RemovedInAirflow3Warning, + stacklevel=2, + ) + find_user = get_airflow_app().appbuilder.sm.find_user @wraps(function) def decorated(*args, **kwargs): @@ -140,7 +154,7 @@ def decorated(*args, **kwargs): token = "".join(header.split()[1:]) return_code = _gssapi_authenticate(token) if return_code == kerberos.AUTH_GSS_COMPLETE: - g.user = get_airflow_app().appbuilder.sm.find_user(username=ctx.kerberos_user) + g.user = find_user(ctx.kerberos_user) response = function(*args, **kwargs) response = make_response(response) if ctx.kerberos_token is not None: diff --git a/airflow/auth/managers/fab/api/__init__.py b/airflow/auth/managers/fab/api/__init__.py new file mode 100644 index 0000000000000..217e5db960782 --- /dev/null +++ b/airflow/auth/managers/fab/api/__init__.py @@ -0,0 +1,17 @@ +# +# 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. diff --git a/airflow/auth/managers/fab/api/auth/__init__.py b/airflow/auth/managers/fab/api/auth/__init__.py new file mode 100644 index 0000000000000..217e5db960782 --- /dev/null +++ b/airflow/auth/managers/fab/api/auth/__init__.py @@ -0,0 +1,17 @@ +# +# 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. diff --git a/airflow/auth/managers/fab/api/auth/backend/__init__.py b/airflow/auth/managers/fab/api/auth/backend/__init__.py new file mode 100644 index 0000000000000..217e5db960782 --- /dev/null +++ b/airflow/auth/managers/fab/api/auth/backend/__init__.py @@ -0,0 +1,17 @@ +# +# 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. diff --git a/airflow/auth/managers/fab/api/auth/backend/basic_auth.py b/airflow/auth/managers/fab/api/auth/backend/basic_auth.py new file mode 100644 index 0000000000000..aa7bdf303e3fb --- /dev/null +++ b/airflow/auth/managers/fab/api/auth/backend/basic_auth.py @@ -0,0 +1,68 @@ +# 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 __future__ import annotations + +from functools import wraps +from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast + +from flask import Response, request +from flask_appbuilder.const import AUTH_LDAP +from flask_login import login_user + +from airflow.utils.airflow_flask_app import get_airflow_app + +if TYPE_CHECKING: + from airflow.auth.managers.fab.models import User + +CLIENT_AUTH: tuple[str, str] | Any | None = None + +T = TypeVar("T", bound=Callable) + + +def init_app(_): + """Initialize authentication backend.""" + + +def auth_current_user() -> User | None: + """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 = get_airflow_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): + """Decorate 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/auth/managers/fab/api/auth/backend/kerberos_auth.py b/airflow/auth/managers/fab/api/auth/backend/kerberos_auth.py new file mode 100644 index 0000000000000..b8c0ea0e5e3e0 --- /dev/null +++ b/airflow/auth/managers/fab/api/auth/backend/kerberos_auth.py @@ -0,0 +1,39 @@ +# +# 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. +from __future__ import annotations + +import logging +from functools import partial +from typing import Any + +from requests_kerberos import HTTPKerberosAuth + +from airflow.api.auth.backend.kerberos_auth import ( + init_app as base_init_app, + requires_authentication as base_requires_authentication, +) +from airflow.utils.airflow_flask_app import get_airflow_app + +log = logging.getLogger(__name__) + +CLIENT_AUTH: tuple[str, str] | Any | None = HTTPKerberosAuth(service="airflow") + +init_app = base_init_app +requires_authentication = partial( + base_requires_authentication, find_user=get_airflow_app().appbuilder.sm.find_user +) diff --git a/airflow/auth/managers/fab/security_manager/override.py b/airflow/auth/managers/fab/security_manager/override.py index cd5cb868048ec..be1fffa9547d3 100644 --- a/airflow/auth/managers/fab/security_manager/override.py +++ b/airflow/auth/managers/fab/security_manager/override.py @@ -30,7 +30,18 @@ import re2 from flask import flash, g, session from flask_appbuilder import const -from flask_appbuilder.const import AUTH_DB, AUTH_LDAP, AUTH_OAUTH, AUTH_OID, AUTH_REMOTE_USER +from flask_appbuilder.const import ( + AUTH_DB, + AUTH_LDAP, + AUTH_OAUTH, + AUTH_OID, + AUTH_REMOTE_USER, + LOGMSG_ERR_SEC_ADD_REGISTER_USER, + LOGMSG_ERR_SEC_AUTH_LDAP, + LOGMSG_ERR_SEC_AUTH_LDAP_TLS, + LOGMSG_WAR_SEC_LOGIN_FAILED, + LOGMSG_WAR_SEC_NOLDAP_OBJ, +) from flask_appbuilder.models.sqla import Base from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import lazy_gettext @@ -40,7 +51,7 @@ from markupsafe import Markup from sqlalchemy import func, inspect, select from sqlalchemy.exc import MultipleResultsFound -from werkzeug.security import generate_password_hash +from werkzeug.security import check_password_hash, generate_password_hash from airflow.auth.managers.fab.fab_auth_manager import MAP_METHOD_NAME_TO_FAB_ACTION_NAME from airflow.auth.managers.fab.models import Action, Permission, RegisterUser, Resource, Role @@ -329,6 +340,101 @@ def oauth_providers(self): """Oauth providers.""" return self.appbuilder.app.config["OAUTH_PROVIDERS"] + @property + def auth_ldap_tls_cacertdir(self): + """LDAP TLS CA certificate directory.""" + return self.appbuilder.get_app.config["AUTH_LDAP_TLS_CACERTDIR"] + + @property + def auth_ldap_tls_cacertfile(self): + """LDAP TLS CA certificate file.""" + return self.appbuilder.get_app.config["AUTH_LDAP_TLS_CACERTFILE"] + + @property + def auth_ldap_tls_certfile(self): + """LDAP TLS certificate file.""" + return self.appbuilder.get_app.config["AUTH_LDAP_TLS_CERTFILE"] + + @property + def auth_ldap_tls_keyfile(self): + """LDAP TLS key file.""" + return self.appbuilder.get_app.config["AUTH_LDAP_TLS_KEYFILE"] + + @property + def auth_ldap_allow_self_signed(self): + """LDAP allow self signed.""" + return self.appbuilder.get_app.config["AUTH_LDAP_ALLOW_SELF_SIGNED"] + + @property + def auth_ldap_tls_demand(self): + """LDAP TLS demand.""" + return self.appbuilder.get_app.config["AUTH_LDAP_TLS_DEMAND"] + + @property + def auth_ldap_server(self): + """Gets the LDAP server object.""" + return self.appbuilder.get_app.config["AUTH_LDAP_SERVER"] + + @property + def auth_ldap_use_tls(self): + """Should LDAP use TLS.""" + return self.appbuilder.get_app.config["AUTH_LDAP_USE_TLS"] + + @property + def auth_ldap_bind_user(self): + """LDAP bind user.""" + return self.appbuilder.get_app.config["AUTH_LDAP_BIND_USER"] + + @property + def auth_ldap_bind_password(self): + """LDAP bind password.""" + return self.appbuilder.get_app.config["AUTH_LDAP_BIND_PASSWORD"] + + @property + def auth_ldap_search(self): + """LDAP search object.""" + return self.appbuilder.get_app.config["AUTH_LDAP_SEARCH"] + + @property + def auth_ldap_search_filter(self): + """LDAP search filter.""" + return self.appbuilder.get_app.config["AUTH_LDAP_SEARCH_FILTER"] + + @property + def auth_ldap_uid_field(self): + """LDAP UID field.""" + return self.appbuilder.get_app.config["AUTH_LDAP_UID_FIELD"] + + @property + def auth_ldap_firstname_field(self): + """LDAP first name field.""" + return self.appbuilder.get_app.config["AUTH_LDAP_FIRSTNAME_FIELD"] + + @property + def auth_ldap_lastname_field(self): + """LDAP last name field.""" + return self.appbuilder.get_app.config["AUTH_LDAP_LASTNAME_FIELD"] + + @property + def auth_ldap_email_field(self): + """LDAP email field.""" + return self.appbuilder.get_app.config["AUTH_LDAP_EMAIL_FIELD"] + + @property + def auth_ldap_append_domain(self): + """LDAP append domain.""" + return self.appbuilder.get_app.config["AUTH_LDAP_APPEND_DOMAIN"] + + @property + def auth_ldap_username_format(self): + """LDAP username format.""" + return self.appbuilder.get_app.config["AUTH_LDAP_USERNAME_FORMAT"] + + @property + def auth_ldap_group_field(self) -> str: + """LDAP group field.""" + return self.appbuilder.get_app.config["AUTH_LDAP_GROUP_FIELD"] + @property def oauth_whitelists(self): warnings.warn( @@ -1062,6 +1168,228 @@ def remove_permission_from_role(self, role: Role, permission: Permission) -> Non log.error(const.LOGMSG_ERR_SEC_DEL_PERMROLE, e) self.get_session.rollback() + """ + -------------------- + Auth related methods + -------------------- + """ + + def auth_user_ldap(self, username, password): + """ + Authenticate user with LDAP. + + NOTE: this depends on python-ldap module. + + :param username: the username + :param password: the password + """ + # If no username is provided, go away + if (username is None) or username == "": + return None + + # Search the DB for this user + user = self.find_user(username=username) + + # If user is not active, go away + if user and (not user.is_active): + return None + + # If user is not registered, and not self-registration, go away + if (not user) and (not self.auth_user_registration): + return None + + # Ensure python-ldap is installed + try: + import ldap + except ImportError: + log.error("python-ldap library is not installed") + return None + + try: + # LDAP certificate settings + if self.auth_ldap_tls_cacertdir: + ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.auth_ldap_tls_cacertdir) + if self.auth_ldap_tls_cacertfile: + ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.auth_ldap_tls_cacertfile) + if self.auth_ldap_tls_certfile: + ldap.set_option(ldap.OPT_X_TLS_CERTFILE, self.auth_ldap_tls_certfile) + if self.auth_ldap_tls_keyfile: + ldap.set_option(ldap.OPT_X_TLS_KEYFILE, self.auth_ldap_tls_keyfile) + if self.auth_ldap_allow_self_signed: + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW) + ldap.set_option(ldap.OPT_X_TLS_NEWCTX, 0) + elif self.auth_ldap_tls_demand: + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND) + ldap.set_option(ldap.OPT_X_TLS_NEWCTX, 0) + + # Initialise LDAP connection + con = ldap.initialize(self.auth_ldap_server) + con.set_option(ldap.OPT_REFERRALS, 0) + if self.auth_ldap_use_tls: + try: + con.start_tls_s() + except Exception: + log.error(LOGMSG_ERR_SEC_AUTH_LDAP_TLS, self.auth_ldap_server) + return None + + # Define variables, so we can check if they are set in later steps + user_dn = None + user_attributes = {} + + # Flow 1 - (Indirect Search Bind): + # - in this flow, special bind credentials are used to perform the + # LDAP search + # - in this flow, AUTH_LDAP_SEARCH must be set + if self.auth_ldap_bind_user: + # Bind with AUTH_LDAP_BIND_USER/AUTH_LDAP_BIND_PASSWORD + # (authorizes for LDAP search) + self._ldap_bind_indirect(ldap, con) + + # Search for `username` + # - returns the `user_dn` needed for binding to validate credentials + # - returns the `user_attributes` needed for + # AUTH_USER_REGISTRATION/AUTH_ROLES_SYNC_AT_LOGIN + if self.auth_ldap_search: + user_dn, user_attributes = self._search_ldap(ldap, con, username) + else: + log.error("AUTH_LDAP_SEARCH must be set when using AUTH_LDAP_BIND_USER") + return None + + # If search failed, go away + if user_dn is None: + log.info(LOGMSG_WAR_SEC_NOLDAP_OBJ, username) + return None + + # Bind with user_dn/password (validates credentials) + if not self._ldap_bind(ldap, con, user_dn, password): + if user: + self.update_user_auth_stat(user, False) + + # Invalid credentials, go away + log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username) + return None + + # Flow 2 - (Direct Search Bind): + # - in this flow, the credentials provided by the end-user are used + # to perform the LDAP search + # - in this flow, we only search LDAP if AUTH_LDAP_SEARCH is set + # - features like AUTH_USER_REGISTRATION & AUTH_ROLES_SYNC_AT_LOGIN + # will only work if AUTH_LDAP_SEARCH is set + else: + # Copy the provided username (so we can apply formatters) + bind_username = username + + # update `bind_username` by applying AUTH_LDAP_APPEND_DOMAIN + # - for Microsoft AD, which allows binding with userPrincipalName + if self.auth_ldap_append_domain: + bind_username = bind_username + "@" + self.auth_ldap_append_domain + + # Update `bind_username` by applying AUTH_LDAP_USERNAME_FORMAT + # - for transforming the username into a DN, + # for example: "uid=%s,ou=example,o=test" + if self.auth_ldap_username_format: + bind_username = self.auth_ldap_username_format % bind_username + + # Bind with bind_username/password + # (validates credentials & authorizes for LDAP search) + if not self._ldap_bind(ldap, con, bind_username, password): + if user: + self.update_user_auth_stat(user, False) + + # Invalid credentials, go away + log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, bind_username) + return None + + # Search for `username` (if AUTH_LDAP_SEARCH is set) + # - returns the `user_attributes` + # needed for AUTH_USER_REGISTRATION/AUTH_ROLES_SYNC_AT_LOGIN + # - we search on `username` not `bind_username`, + # because AUTH_LDAP_APPEND_DOMAIN and AUTH_LDAP_USERNAME_FORMAT + # would result in an invalid search filter + if self.auth_ldap_search: + user_dn, user_attributes = self._search_ldap(ldap, con, username) + + # If search failed, go away + if user_dn is None: + log.info(LOGMSG_WAR_SEC_NOLDAP_OBJ, username) + return None + + # Sync the user's roles + if user and user_attributes and self.auth_roles_sync_at_login: + user.roles = self._ldap_calculate_user_roles(user_attributes) + log.debug("Calculated new roles for user=%r as: %s", user_dn, user.roles) + + # If the user is new, register them + if (not user) and user_attributes and self.auth_user_registration: + user = self.add_user( + username=username, + first_name=self.ldap_extract(user_attributes, self.auth_ldap_firstname_field, ""), + last_name=self.ldap_extract(user_attributes, self.auth_ldap_lastname_field, ""), + email=self.ldap_extract( + user_attributes, + self.auth_ldap_email_field, + f"{username}@email.notfound", + ), + role=self._ldap_calculate_user_roles(user_attributes), + ) + log.debug("New user registered: %s", user) + + # If user registration failed, go away + if not user: + log.info(LOGMSG_ERR_SEC_ADD_REGISTER_USER, username) + return None + + # LOGIN SUCCESS (only if user is now registered) + if user: + self._rotate_session_id() + self.update_user_auth_stat(user) + return user + else: + return None + + except ldap.LDAPError as e: + msg = None + if isinstance(e, dict): + msg = getattr(e, "message", None) + if (msg is not None) and ("desc" in msg): + log.error(LOGMSG_ERR_SEC_AUTH_LDAP, e.message["desc"]) + return None + else: + log.error(e) + return None + + def auth_user_db(self, username, password): + """ + Authenticate user, auth db style. + + :param username: + The username or registered email address + :param password: + The password, will be tested against hashed password on db + """ + if username is None or username == "": + return None + user = self.find_user(username=username) + if user is None: + user = self.find_user(email=username) + if user is None or (not user.is_active): + # Balance failure and success + check_password_hash( + "pbkdf2:sha256:150000$Z3t6fmj2$22da622d94a1f8118" + "c0976a03d2f18f680bfff877c9a965db9eedc51bc0be87c", + "password", + ) + log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username) + return None + elif check_password_hash(user.password, password): + self._rotate_session_id() + self.update_user_auth_stat(user, True) + return user + else: + self.update_user_auth_stat(user, False) + log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username) + return None + def get_oauth_user_info(self, provider, resp): """ Get the OAuth user information from different OAuth APIs. @@ -1189,6 +1517,24 @@ def check_authorization( return True + @staticmethod + def ldap_extract_list(ldap_dict: dict[str, list[bytes]], field_name: str) -> list[str]: + raw_list = ldap_dict.get(field_name, []) + # decode - removing empty strings + return [x.decode("utf-8") for x in raw_list if x.decode("utf-8")] + + @staticmethod + def ldap_extract(ldap_dict: dict[str, list[bytes]], field_name: str, fallback: str) -> str: + raw_value = ldap_dict.get(field_name, [b""]) + # decode - if empty string, default to fallback, otherwise take first element + return raw_value[0].decode("utf-8") or fallback + + """ + --------------- + Private methods + --------------- + """ + @staticmethod def _azure_parse_jwt(token): """ @@ -1235,3 +1581,113 @@ def _azure_jwt_token_parse(token): jwt_decoded_payload = json.loads(decoded_payload.decode("utf-8")) return jwt_decoded_payload + + def _ldap_bind_indirect(self, ldap, con) -> None: + """ + Attempt to bind to LDAP using the AUTH_LDAP_BIND_USER. + + :param ldap: The ldap module reference + :param con: The ldap connection + """ + # always check AUTH_LDAP_BIND_USER is set before calling this method + assert self.auth_ldap_bind_user, "AUTH_LDAP_BIND_USER must be set" + + try: + log.debug("LDAP bind indirect TRY with username: %r", self.auth_ldap_bind_user) + con.simple_bind_s(self.auth_ldap_bind_user, self.auth_ldap_bind_password) + log.debug("LDAP bind indirect SUCCESS with username: %r", self.auth_ldap_bind_user) + except ldap.INVALID_CREDENTIALS as ex: + log.error("AUTH_LDAP_BIND_USER and AUTH_LDAP_BIND_PASSWORD are not valid LDAP bind credentials") + raise ex + + def _search_ldap(self, ldap, con, username): + """ + Search LDAP for user. + + :param ldap: The ldap module reference + :param con: The ldap connection + :param username: username to match with AUTH_LDAP_UID_FIELD + :return: ldap object array + """ + # always check AUTH_LDAP_SEARCH is set before calling this method + assert self.auth_ldap_search, "AUTH_LDAP_SEARCH must be set" + + # build the filter string for the LDAP search + if self.auth_ldap_search_filter: + filter_str = f"(&{self.auth_ldap_search_filter}({self.auth_ldap_uid_field}={username}))" + else: + filter_str = f"({self.auth_ldap_uid_field}={username})" + + # build what fields to request in the LDAP search + request_fields = [ + self.auth_ldap_firstname_field, + self.auth_ldap_lastname_field, + self.auth_ldap_email_field, + ] + if self.auth_roles_mapping: + request_fields.append(self.auth_ldap_group_field) + + # perform the LDAP search + log.debug( + "LDAP search for %r with fields %s in scope %r", filter_str, request_fields, self.auth_ldap_search + ) + raw_search_result = con.search_s( + self.auth_ldap_search, ldap.SCOPE_SUBTREE, filter_str, request_fields + ) + log.debug("LDAP search returned: %s", raw_search_result) + + # Remove any search referrals from results + search_result = [ + (dn, attrs) for dn, attrs in raw_search_result if dn is not None and isinstance(attrs, dict) + ] + + # only continue if 0 or 1 results were returned + if len(search_result) > 1: + log.error( + "LDAP search for %r in scope '%a' returned multiple results", + self.auth_ldap_search, + filter_str, + ) + return None, None + + try: + # extract the DN + user_dn = search_result[0][0] + # extract the other attributes + user_info = search_result[0][1] + # return + return user_dn, user_info + except (IndexError, NameError): + return None, None + + @staticmethod + def _ldap_bind(ldap, con, dn: str, password: str) -> bool: + """Validates/binds the provided dn/password with the LDAP sever.""" + try: + log.debug("LDAP bind TRY with username: %r", dn) + con.simple_bind_s(dn, password) + log.debug("LDAP bind SUCCESS with username: %r", dn) + return True + except ldap.INVALID_CREDENTIALS: + return False + + def _ldap_calculate_user_roles(self, user_attributes: dict[str, list[bytes]]) -> list[str]: + user_role_objects = set() + + # apply AUTH_ROLES_MAPPING + if self.auth_roles_mapping: + user_role_keys = self.ldap_extract_list(user_attributes, self.auth_ldap_group_field) + user_role_objects.update(self.get_roles_from_keys(user_role_keys)) + + # apply AUTH_USER_REGISTRATION + if self.auth_user_registration: + registration_role_name = self.auth_user_registration_role + + # lookup registration role in flask db + fab_role = self.find_role(registration_role_name) + if fab_role: + user_role_objects.add(fab_role) + else: + log.warning("Can't find AUTH_USER_REGISTRATION role: %s", registration_role_name) + + return list(user_role_objects) diff --git a/airflow/www/fab_security/manager.py b/airflow/www/fab_security/manager.py index 21ca6a511aee8..b9d63faf56514 100644 --- a/airflow/www/fab_security/manager.py +++ b/airflow/www/fab_security/manager.py @@ -28,11 +28,7 @@ from flask_appbuilder.const import ( AUTH_DB, AUTH_LDAP, - LOGMSG_ERR_SEC_ADD_REGISTER_USER, - LOGMSG_ERR_SEC_AUTH_LDAP, - LOGMSG_ERR_SEC_AUTH_LDAP_TLS, LOGMSG_WAR_SEC_LOGIN_FAILED, - LOGMSG_WAR_SEC_NOLDAP_OBJ, ) from flask_appbuilder.security.registerviews import ( RegisterUserDBView, @@ -60,7 +56,6 @@ from flask_jwt_extended import current_user as current_user_jwt from flask_limiter import Limiter from flask_limiter.util import get_remote_address -from werkzeug.security import check_password_hash from airflow.configuration import conf from airflow.www.extensions.init_auth_manager import get_auth_manager @@ -238,16 +233,6 @@ def auth_role_admin(self): """Gets the admin role.""" return self.appbuilder.get_app.config["AUTH_ROLE_ADMIN"] - @property - def auth_ldap_server(self): - """Gets the LDAP server object.""" - return self.appbuilder.get_app.config["AUTH_LDAP_SERVER"] - - @property - def auth_ldap_use_tls(self): - """Should LDAP use TLS.""" - return self.appbuilder.get_app.config["AUTH_LDAP_USE_TLS"] - @property def auth_user_registration(self): """Will user self registration be allowed.""" @@ -273,96 +258,11 @@ def auth_roles_sync_at_login(self) -> bool: """Should roles be synced at login.""" return self.appbuilder.get_app.config["AUTH_ROLES_SYNC_AT_LOGIN"] - @property - def auth_ldap_search(self): - """LDAP search object.""" - return self.appbuilder.get_app.config["AUTH_LDAP_SEARCH"] - - @property - def auth_ldap_search_filter(self): - """LDAP search filter.""" - return self.appbuilder.get_app.config["AUTH_LDAP_SEARCH_FILTER"] - - @property - def auth_ldap_bind_user(self): - """LDAP bind user.""" - return self.appbuilder.get_app.config["AUTH_LDAP_BIND_USER"] - - @property - def auth_ldap_bind_password(self): - """LDAP bind password.""" - return self.appbuilder.get_app.config["AUTH_LDAP_BIND_PASSWORD"] - - @property - def auth_ldap_append_domain(self): - """LDAP append domain.""" - return self.appbuilder.get_app.config["AUTH_LDAP_APPEND_DOMAIN"] - - @property - def auth_ldap_username_format(self): - """LDAP username format.""" - return self.appbuilder.get_app.config["AUTH_LDAP_USERNAME_FORMAT"] - - @property - def auth_ldap_uid_field(self): - """LDAP UID field.""" - return self.appbuilder.get_app.config["AUTH_LDAP_UID_FIELD"] - - @property - def auth_ldap_group_field(self) -> str: - """LDAP group field.""" - return self.appbuilder.get_app.config["AUTH_LDAP_GROUP_FIELD"] - - @property - def auth_ldap_firstname_field(self): - """LDAP first name field.""" - return self.appbuilder.get_app.config["AUTH_LDAP_FIRSTNAME_FIELD"] - - @property - def auth_ldap_lastname_field(self): - """LDAP last name field.""" - return self.appbuilder.get_app.config["AUTH_LDAP_LASTNAME_FIELD"] - - @property - def auth_ldap_email_field(self): - """LDAP email field.""" - return self.appbuilder.get_app.config["AUTH_LDAP_EMAIL_FIELD"] - @property def auth_ldap_bind_first(self): """LDAP bind first.""" return self.appbuilder.get_app.config["AUTH_LDAP_BIND_FIRST"] - @property - def auth_ldap_allow_self_signed(self): - """LDAP allow self signed.""" - return self.appbuilder.get_app.config["AUTH_LDAP_ALLOW_SELF_SIGNED"] - - @property - def auth_ldap_tls_demand(self): - """LDAP TLS demand.""" - return self.appbuilder.get_app.config["AUTH_LDAP_TLS_DEMAND"] - - @property - def auth_ldap_tls_cacertdir(self): - """LDAP TLS CA certificate directory.""" - return self.appbuilder.get_app.config["AUTH_LDAP_TLS_CACERTDIR"] - - @property - def auth_ldap_tls_cacertfile(self): - """LDAP TLS CA certificate file.""" - return self.appbuilder.get_app.config["AUTH_LDAP_TLS_CACERTFILE"] - - @property - def auth_ldap_tls_certfile(self): - """LDAP TLS certificate file.""" - return self.appbuilder.get_app.config["AUTH_LDAP_TLS_CERTFILE"] - - @property - def auth_ldap_tls_keyfile(self): - """LDAP TLS key file.""" - return self.appbuilder.get_app.config["AUTH_LDAP_TLS_KEYFILE"] - @property def openid_providers(self): """Openid providers.""" @@ -472,344 +372,6 @@ def _rotate_session_id(self): if conf.get("webserver", "SESSION_BACKEND") == "database": session.sid = str(uuid4()) - def auth_user_db(self, username, password): - """ - Authenticate user, auth db style. - - :param username: - The username or registered email address - :param password: - The password, will be tested against hashed password on db - """ - if username is None or username == "": - return None - user = self.find_user(username=username) - if user is None: - user = self.find_user(email=username) - if user is None or (not user.is_active): - # Balance failure and success - check_password_hash( - "pbkdf2:sha256:150000$Z3t6fmj2$22da622d94a1f8118" - "c0976a03d2f18f680bfff877c9a965db9eedc51bc0be87c", - "password", - ) - log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username) - return None - elif check_password_hash(user.password, password): - self._rotate_session_id() - self.update_user_auth_stat(user, True) - return user - else: - self.update_user_auth_stat(user, False) - log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username) - return None - - def _search_ldap(self, ldap, con, username): - """ - Search LDAP for user. - - :param ldap: The ldap module reference - :param con: The ldap connection - :param username: username to match with AUTH_LDAP_UID_FIELD - :return: ldap object array - """ - # always check AUTH_LDAP_SEARCH is set before calling this method - assert self.auth_ldap_search, "AUTH_LDAP_SEARCH must be set" - - # build the filter string for the LDAP search - if self.auth_ldap_search_filter: - filter_str = f"(&{self.auth_ldap_search_filter}({self.auth_ldap_uid_field}={username}))" - else: - filter_str = f"({self.auth_ldap_uid_field}={username})" - - # build what fields to request in the LDAP search - request_fields = [ - self.auth_ldap_firstname_field, - self.auth_ldap_lastname_field, - self.auth_ldap_email_field, - ] - if self.auth_roles_mapping: - request_fields.append(self.auth_ldap_group_field) - - # perform the LDAP search - log.debug( - "LDAP search for %r with fields %s in scope %r", filter_str, request_fields, self.auth_ldap_search - ) - raw_search_result = con.search_s( - self.auth_ldap_search, ldap.SCOPE_SUBTREE, filter_str, request_fields - ) - log.debug("LDAP search returned: %s", raw_search_result) - - # Remove any search referrals from results - search_result = [ - (dn, attrs) for dn, attrs in raw_search_result if dn is not None and isinstance(attrs, dict) - ] - - # only continue if 0 or 1 results were returned - if len(search_result) > 1: - log.error( - "LDAP search for %r in scope '%a' returned multiple results", - self.auth_ldap_search, - filter_str, - ) - return None, None - - try: - # extract the DN - user_dn = search_result[0][0] - # extract the other attributes - user_info = search_result[0][1] - # return - return user_dn, user_info - except (IndexError, NameError): - return None, None - - def _ldap_calculate_user_roles(self, user_attributes: dict[str, list[bytes]]) -> list[str]: - user_role_objects = set() - - # apply AUTH_ROLES_MAPPING - if self.auth_roles_mapping: - user_role_keys = self.ldap_extract_list(user_attributes, self.auth_ldap_group_field) - user_role_objects.update(self.get_roles_from_keys(user_role_keys)) - - # apply AUTH_USER_REGISTRATION - if self.auth_user_registration: - registration_role_name = self.auth_user_registration_role - - # lookup registration role in flask db - fab_role = self.find_role(registration_role_name) - if fab_role: - user_role_objects.add(fab_role) - else: - log.warning("Can't find AUTH_USER_REGISTRATION role: %s", registration_role_name) - - return list(user_role_objects) - - def _ldap_bind_indirect(self, ldap, con) -> None: - """ - Attempt to bind to LDAP using the AUTH_LDAP_BIND_USER. - - :param ldap: The ldap module reference - :param con: The ldap connection - """ - # always check AUTH_LDAP_BIND_USER is set before calling this method - assert self.auth_ldap_bind_user, "AUTH_LDAP_BIND_USER must be set" - - try: - log.debug("LDAP bind indirect TRY with username: %r", self.auth_ldap_bind_user) - con.simple_bind_s(self.auth_ldap_bind_user, self.auth_ldap_bind_password) - log.debug("LDAP bind indirect SUCCESS with username: %r", self.auth_ldap_bind_user) - except ldap.INVALID_CREDENTIALS as ex: - log.error("AUTH_LDAP_BIND_USER and AUTH_LDAP_BIND_PASSWORD are not valid LDAP bind credentials") - raise ex - - @staticmethod - def _ldap_bind(ldap, con, dn: str, password: str) -> bool: - """Validates/binds the provided dn/password with the LDAP sever.""" - try: - log.debug("LDAP bind TRY with username: %r", dn) - con.simple_bind_s(dn, password) - log.debug("LDAP bind SUCCESS with username: %r", dn) - return True - except ldap.INVALID_CREDENTIALS: - return False - - @staticmethod - def ldap_extract(ldap_dict: dict[str, list[bytes]], field_name: str, fallback: str) -> str: - raw_value = ldap_dict.get(field_name, [b""]) - # decode - if empty string, default to fallback, otherwise take first element - return raw_value[0].decode("utf-8") or fallback - - @staticmethod - def ldap_extract_list(ldap_dict: dict[str, list[bytes]], field_name: str) -> list[str]: - raw_list = ldap_dict.get(field_name, []) - # decode - removing empty strings - return [x.decode("utf-8") for x in raw_list if x.decode("utf-8")] - - def auth_user_ldap(self, username, password): - """ - Authenticate user with LDAP. - - NOTE: this depends on python-ldap module. - - :param username: the username - :param password: the password - """ - # If no username is provided, go away - if (username is None) or username == "": - return None - - # Search the DB for this user - user = self.find_user(username=username) - - # If user is not active, go away - if user and (not user.is_active): - return None - - # If user is not registered, and not self-registration, go away - if (not user) and (not self.auth_user_registration): - return None - - # Ensure python-ldap is installed - try: - import ldap - except ImportError: - log.error("python-ldap library is not installed") - return None - - try: - # LDAP certificate settings - if self.auth_ldap_tls_cacertdir: - ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.auth_ldap_tls_cacertdir) - if self.auth_ldap_tls_cacertfile: - ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.auth_ldap_tls_cacertfile) - if self.auth_ldap_tls_certfile: - ldap.set_option(ldap.OPT_X_TLS_CERTFILE, self.auth_ldap_tls_certfile) - if self.auth_ldap_tls_keyfile: - ldap.set_option(ldap.OPT_X_TLS_KEYFILE, self.auth_ldap_tls_keyfile) - if self.auth_ldap_allow_self_signed: - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW) - ldap.set_option(ldap.OPT_X_TLS_NEWCTX, 0) - elif self.auth_ldap_tls_demand: - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND) - ldap.set_option(ldap.OPT_X_TLS_NEWCTX, 0) - - # Initialise LDAP connection - con = ldap.initialize(self.auth_ldap_server) - con.set_option(ldap.OPT_REFERRALS, 0) - if self.auth_ldap_use_tls: - try: - con.start_tls_s() - except Exception: - log.error(LOGMSG_ERR_SEC_AUTH_LDAP_TLS, self.auth_ldap_server) - return None - - # Define variables, so we can check if they are set in later steps - user_dn = None - user_attributes = {} - - # Flow 1 - (Indirect Search Bind): - # - in this flow, special bind credentials are used to perform the - # LDAP search - # - in this flow, AUTH_LDAP_SEARCH must be set - if self.auth_ldap_bind_user: - # Bind with AUTH_LDAP_BIND_USER/AUTH_LDAP_BIND_PASSWORD - # (authorizes for LDAP search) - self._ldap_bind_indirect(ldap, con) - - # Search for `username` - # - returns the `user_dn` needed for binding to validate credentials - # - returns the `user_attributes` needed for - # AUTH_USER_REGISTRATION/AUTH_ROLES_SYNC_AT_LOGIN - if self.auth_ldap_search: - user_dn, user_attributes = self._search_ldap(ldap, con, username) - else: - log.error("AUTH_LDAP_SEARCH must be set when using AUTH_LDAP_BIND_USER") - return None - - # If search failed, go away - if user_dn is None: - log.info(LOGMSG_WAR_SEC_NOLDAP_OBJ, username) - return None - - # Bind with user_dn/password (validates credentials) - if not self._ldap_bind(ldap, con, user_dn, password): - if user: - self.update_user_auth_stat(user, False) - - # Invalid credentials, go away - log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, username) - return None - - # Flow 2 - (Direct Search Bind): - # - in this flow, the credentials provided by the end-user are used - # to perform the LDAP search - # - in this flow, we only search LDAP if AUTH_LDAP_SEARCH is set - # - features like AUTH_USER_REGISTRATION & AUTH_ROLES_SYNC_AT_LOGIN - # will only work if AUTH_LDAP_SEARCH is set - else: - # Copy the provided username (so we can apply formatters) - bind_username = username - - # update `bind_username` by applying AUTH_LDAP_APPEND_DOMAIN - # - for Microsoft AD, which allows binding with userPrincipalName - if self.auth_ldap_append_domain: - bind_username = bind_username + "@" + self.auth_ldap_append_domain - - # Update `bind_username` by applying AUTH_LDAP_USERNAME_FORMAT - # - for transforming the username into a DN, - # for example: "uid=%s,ou=example,o=test" - if self.auth_ldap_username_format: - bind_username = self.auth_ldap_username_format % bind_username - - # Bind with bind_username/password - # (validates credentials & authorizes for LDAP search) - if not self._ldap_bind(ldap, con, bind_username, password): - if user: - self.update_user_auth_stat(user, False) - - # Invalid credentials, go away - log.info(LOGMSG_WAR_SEC_LOGIN_FAILED, bind_username) - return None - - # Search for `username` (if AUTH_LDAP_SEARCH is set) - # - returns the `user_attributes` - # needed for AUTH_USER_REGISTRATION/AUTH_ROLES_SYNC_AT_LOGIN - # - we search on `username` not `bind_username`, - # because AUTH_LDAP_APPEND_DOMAIN and AUTH_LDAP_USERNAME_FORMAT - # would result in an invalid search filter - if self.auth_ldap_search: - user_dn, user_attributes = self._search_ldap(ldap, con, username) - - # If search failed, go away - if user_dn is None: - log.info(LOGMSG_WAR_SEC_NOLDAP_OBJ, username) - return None - - # Sync the user's roles - if user and user_attributes and self.auth_roles_sync_at_login: - user.roles = self._ldap_calculate_user_roles(user_attributes) - log.debug("Calculated new roles for user=%r as: %s", user_dn, user.roles) - - # If the user is new, register them - if (not user) and user_attributes and self.auth_user_registration: - user = self.add_user( - username=username, - first_name=self.ldap_extract(user_attributes, self.auth_ldap_firstname_field, ""), - last_name=self.ldap_extract(user_attributes, self.auth_ldap_lastname_field, ""), - email=self.ldap_extract( - user_attributes, - self.auth_ldap_email_field, - f"{username}@email.notfound", - ), - role=self._ldap_calculate_user_roles(user_attributes), - ) - log.debug("New user registered: %s", user) - - # If user registration failed, go away - if not user: - log.info(LOGMSG_ERR_SEC_ADD_REGISTER_USER, username) - return None - - # LOGIN SUCCESS (only if user is now registered) - if user: - self._rotate_session_id() - self.update_user_auth_stat(user) - return user - else: - return None - - except ldap.LDAPError as e: - msg = None - if isinstance(e, dict): - msg = getattr(e, "message", None) - if (msg is not None) and ("desc" in msg): - log.error(LOGMSG_ERR_SEC_AUTH_LDAP, e.message["desc"]) - return None - else: - log.error(e) - return None - def auth_user_oid(self, email): """ Openid user Authentication. diff --git a/tests/test_utils/www.py b/tests/test_utils/www.py index ea0295af2c726..bd9e604097b54 100644 --- a/tests/test_utils/www.py +++ b/tests/test_utils/www.py @@ -23,7 +23,7 @@ def client_with_login(app, expected_response_code=302, **kwargs): - patch_path = "airflow.www.fab_security.manager.check_password_hash" + patch_path = "airflow.auth.managers.fab.security_manager.override.check_password_hash" with mock.patch(patch_path) as check_password_hash: check_password_hash.return_value = True client = app.test_client() diff --git a/tests/www/views/test_session.py b/tests/www/views/test_session.py index 822cf967fda56..25f40a28700ef 100644 --- a/tests/www/views/test_session.py +++ b/tests/www/views/test_session.py @@ -79,7 +79,7 @@ def test_session_id_rotates(app, user_client): resp = user_client.get("/logout/") assert resp.status_code == 302 - patch_path = "airflow.www.fab_security.manager.check_password_hash" + patch_path = "airflow.auth.managers.fab.security_manager.override.check_password_hash" with mock.patch(patch_path) as check_password_hash: check_password_hash.return_value = True resp = user_client.post("/login/", data={"username": "test_user", "password": "test_user"})