diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c817a69c01595..209a390d5fe2c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -125,7 +125,7 @@ repos: - --fuzzy-match-generates-todo - id: insert-license name: Add license for all YAML files except Helm templates - exclude: ^\.github/.*$|^chart/templates/.*|.*/reproducible_build.yaml$|^airflow/api_fastapi/core_api/openapi/v1-generated.yaml$|^airflow/auth/managers/simple/openapi/v1-generated.yaml$|^.*/pnpm-lock.yaml$ + exclude: ^\.github/.*$|^chart/templates/.*|.*/reproducible_build.yaml$|^airflow/api_fastapi/core_api/openapi/v1-generated.yaml$|^airflow/auth/managers/simple/openapi/v1-generated.yaml$|^providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/v1-generated.yaml$|^.*/pnpm-lock.yaml$ types: [yaml] files: \.ya?ml$ args: @@ -1383,7 +1383,7 @@ repos: language: python entry: ./scripts/ci/pre_commit/update_fastapi_api_spec.py pass_filenames: false - files: ^airflow/api_fastapi/.*\.py$ + files: ^airflow/api_fastapi/.*\.py$|^airflow/auth/managers/simple/.*\.py$|^providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/.*\.py$ exclude: ^airflow/api_fastapi/execution_api/.* additional_dependencies: ['rich>=12.4.4'] - id: generate-tasksdk-datamodels diff --git a/airflow/auth/managers/base_auth_manager.py b/airflow/auth/managers/base_auth_manager.py index ec62677c9db8d..8c8153c5f5c2f 100644 --- a/airflow/auth/managers/base_auth_manager.py +++ b/airflow/auth/managers/base_auth_manager.py @@ -114,9 +114,13 @@ def get_user_from_token(self, token: str) -> BaseUser: log.error("JWT token is not valid") raise e - def get_jwt_token(self, user: T) -> str: + def get_jwt_token( + self, user: T, expiration_time_in_seconds: int = conf.getint("api", "auth_jwt_expiration_time") + ) -> str: """Return the JWT token from a user object.""" - return self._get_token_signer().generate_signed_token(self.serialize_user(user)) + return self._get_token_signer( + expiration_time_in_seconds=expiration_time_in_seconds + ).generate_signed_token(self.serialize_user(user)) def get_user_id(self) -> str | None: """Return the user ID associated to the user in session.""" @@ -457,14 +461,18 @@ def register_views(self) -> None: """Register views specific to the auth manager.""" @staticmethod - def _get_token_signer(): + def _get_token_signer( + expiration_time_in_seconds: int = conf.getint("api", "auth_jwt_expiration_time"), + ) -> JWTSigner: """ Return the signer used to sign JWT token. :meta private: + + :param expiration_time_in_seconds: expiration time in seconds of the token """ return JWTSigner( secret_key=get_signing_key("api", "auth_jwt_secret"), - expiration_time_in_seconds=conf.getint("api", "auth_jwt_expiration_time"), + expiration_time_in_seconds=expiration_time_in_seconds, audience="front-apis", ) diff --git a/airflow/auth/managers/simple/routes/login.py b/airflow/auth/managers/simple/routes/login.py index 382a94d79c196..9ea0f9287bd61 100644 --- a/airflow/auth/managers/simple/routes/login.py +++ b/airflow/auth/managers/simple/routes/login.py @@ -37,7 +37,7 @@ def create_token( body: LoginBody, ) -> LoginResponse: """Authenticate the user.""" - return SimpleAuthManagerLogin.create_token(body, conf.getint("api", "auth_jwt_expiration_time")) + return SimpleAuthManagerLogin.create_token(body=body) @login_router.post( @@ -49,4 +49,6 @@ def create_token_cli( body: LoginBody, ) -> LoginResponse: """Authenticate the user for the CLI.""" - return SimpleAuthManagerLogin.create_token(body, conf.getint("api", "auth_jwt_cli_expiration_time")) + return SimpleAuthManagerLogin.create_token( + body=body, expiration_time_in_sec=conf.getint("api", "auth_jwt_cli_expiration_time") + ) diff --git a/airflow/auth/managers/simple/services/login.py b/airflow/auth/managers/simple/services/login.py index 4d8bb612d66ff..35953382147bd 100644 --- a/airflow/auth/managers/simple/services/login.py +++ b/airflow/auth/managers/simple/services/login.py @@ -23,14 +23,16 @@ from airflow.auth.managers.simple.datamodels.login import LoginBody, LoginResponse from airflow.auth.managers.simple.simple_auth_manager import SimpleAuthManager from airflow.auth.managers.simple.user import SimpleAuthManagerUser -from airflow.utils.jwt_signer import JWTSigner, get_signing_key +from airflow.configuration import conf class SimpleAuthManagerLogin: """Service for login.""" @classmethod - def create_token(cls, body: LoginBody, expiration_time_in_sec: int) -> LoginResponse: + def create_token( + cls, body: LoginBody, expiration_time_in_sec: int = conf.getint("api", "auth_jwt_expiration_time") + ) -> LoginResponse: """ Authenticate user with given configuration. @@ -64,10 +66,8 @@ def create_token(cls, body: LoginBody, expiration_time_in_sec: int) -> LoginResp role=found_users[0]["role"], ) - signer = JWTSigner( - secret_key=get_signing_key("api", "auth_jwt_secret"), - expiration_time_in_seconds=expiration_time_in_sec, - audience="front-apis", + return LoginResponse( + jwt_token=get_auth_manager().get_jwt_token( + user=user, expiration_time_in_seconds=expiration_time_in_sec + ) ) - token = signer.generate_signed_token(get_auth_manager().serialize_user(user)) - return LoginResponse(jwt_token=token) diff --git a/docs/apache-airflow/security/api.rst b/docs/apache-airflow/security/api.rst index b23a06a7d7f0c..0bae61d73634a 100644 --- a/docs/apache-airflow/security/api.rst +++ b/docs/apache-airflow/security/api.rst @@ -21,8 +21,14 @@ API API Authentication ------------------ -The API authentication is handled by the auth manager. For more information about API authentication, please refer to the auth manager documentation used by your environment. -By default Airflow uses the FAB auth manager, if you did not specify any other auth manager, please look at :doc:`apache-airflow-providers-fab:auth-manager/api-authentication`. +The API authentication is handled by the auth manager. +For more information about API authentication, please refer to the auth manager documentation used by your environment. +By default Airflow uses the ``Simple Auth Manager``, if you did not specify any other auth manager. +``Simple Auth Manager`` is a basic auth manager that persisted under Airflow core. +It is not recommended to use it in production and currently aiming for development purposes. + +Please install ``apache-airflow-providers-fab`` to use the auth manager that is aimed for production. +For that, please look at :doc:`apache-airflow-providers-fab:auth-manager/api-authentication`. Enabling CORS ------------- diff --git a/newsfragments/46916.significant.rst b/newsfragments/46916.significant.rst new file mode 100644 index 0000000000000..9f24753713468 --- /dev/null +++ b/newsfragments/46916.significant.rst @@ -0,0 +1,19 @@ +Public API authentication is migrated to JWT token based authentication for default (Simple Auth Manager) and FAB provider. + +The default setting is using API to create a token (JWT) to authenticate the requests to access the API. +The endpoints are populated under ``/auth`` path. +If none of the providers are installed such as FAB, the API will use the default use Simple Auth Manager in the core. + +To integrate the same functioning into API requests using FAB provider. Please install ``apache-airflow-providers-fab``. +For more information, please look at :doc:`apache-airflow-providers-fab:auth-manager/api-authentication`. + +* Types of change + + * [ ] Dag changes + * [ ] Config changes + * [x] API changes + * [ ] CLI changes + * [ ] Behaviour changes + * [ ] Plugin changes + * [ ] Dependency changes + * [ ] Code interface changes diff --git a/providers/fab/docs/auth-manager/api-authentication.rst b/providers/fab/docs/auth-manager/api-authentication.rst index fc3d922fd7501..cfebe3f8cfe2b 100644 --- a/providers/fab/docs/auth-manager/api-authentication.rst +++ b/providers/fab/docs/auth-manager/api-authentication.rst @@ -45,6 +45,36 @@ command as in the example below. $ airflow config get-value api auth_backends airflow.providers.fab.auth_manager.api.auth.backend.basic_auth +.. versionchanged:: 3.0.0 + + In Airflow, the default setting is using token based authentication. + This approach is independent from which ``auth_backend`` is used. + The default setting is using Airflow public API to create a token (JWT) first and use this token in the requests to access the API. + + +JWT Token based authentication +'''''''''''''''''''''''''''''' +The JWT token based authentication is the default setting for the API. +To be able to use the Airflow Public API, you need to create a token first and use this token in the requests to access the API. + +Endpoints are populated under ``/auth`` path. These endpoints are mounted to the Airflow API. +You should use your username and password, as seen in the example below. +The token is valid for seconds defined in ``auth_jwt_expiration_time`` which can be set from ``airflow.cfg``. + +Example of creating a token: +.. code-block:: bash + + curl -X 'POST' \ + 'http://localhost:32784/auth/token' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "username": "username", + "password": "password" + }' + +This process will return a token that you can use in the requests to access the API. + Kerberos authentication ''''''''''''''''''''''' diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/__init__.py b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/__init__.py @@ -0,0 +1,16 @@ +# 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/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/datamodels/__init__.py b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/datamodels/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/datamodels/__init__.py @@ -0,0 +1,16 @@ +# 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/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/datamodels/login.py b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/datamodels/login.py new file mode 100644 index 0000000000000..3a7aaf8a60f69 --- /dev/null +++ b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/datamodels/login.py @@ -0,0 +1,32 @@ +# 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 + +from airflow.api_fastapi.core_api.base import BaseModel + + +class LoginResponse(BaseModel): + """API Token serializer for responses.""" + + jwt_token: str + + +class LoginBody(BaseModel): + """API Token serializer for requests.""" + + username: str + password: str diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/__init__.py b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/__init__.py @@ -0,0 +1,16 @@ +# 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/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/v1-generated.yaml b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/v1-generated.yaml new file mode 100644 index 0000000000000..be326dfa49ed7 --- /dev/null +++ b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/v1-generated.yaml @@ -0,0 +1,152 @@ +openapi: 3.1.0 +info: + title: FAB auth manager API + description: This is FAB auth manager API. This API is only available if the auth + manager used in the Airflow environment is FAB auth manager. This API provides + endpoints to manage users and permissions managed by the FAB auth manager. + version: 0.1.0 +paths: + /token: + post: + tags: + - FabAuthManager + summary: Create Token + description: Generate a new API token. + operationId: create_token + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LoginBody' + required: true + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /token/cli: + post: + tags: + - FabAuthManager + summary: Create Token Cli + description: Generate a new CLI API token. + operationId: create_token_cli + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LoginBody' + required: true + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPExceptionResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + HTTPExceptionResponse: + properties: + detail: + anyOf: + - type: string + - type: object + title: Detail + type: object + required: + - detail + title: HTTPExceptionResponse + description: HTTPException Model used for error response. + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + LoginBody: + properties: + username: + type: string + title: Username + password: + type: string + title: Password + type: object + required: + - username + - password + title: LoginBody + description: API Token serializer for requests. + LoginResponse: + properties: + jwt_token: + type: string + title: Jwt Token + type: object + required: + - jwt_token + title: LoginResponse + description: API Token serializer for responses. + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + type: object + required: + - loc + - msg + - type + title: ValidationError diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/__init__.py b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/__init__.py @@ -0,0 +1,16 @@ +# 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/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/login.py b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/login.py new file mode 100644 index 0000000000000..5776f1e96c338 --- /dev/null +++ b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/login.py @@ -0,0 +1,51 @@ +# 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 + +from starlette import status + +from airflow.api_fastapi.common.router import AirflowRouter +from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc +from airflow.configuration import conf +from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import LoginBody, LoginResponse +from airflow.providers.fab.auth_manager.api_fastapi.services.login import FABAuthManagerLogin + +login_router = AirflowRouter(tags=["FabAuthManager"]) + + +@login_router.post( + "/token", + response_model=LoginResponse, + status_code=status.HTTP_201_CREATED, + responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED]), +) +def create_token(body: LoginBody) -> LoginResponse: + """Generate a new API token.""" + return FABAuthManagerLogin.create_token(body=body) + + +@login_router.post( + "/token/cli", + response_model=LoginResponse, + status_code=status.HTTP_201_CREATED, + responses=create_openapi_http_exception_doc([status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED]), +) +def create_token_cli(body: LoginBody) -> LoginResponse: + """Generate a new CLI API token.""" + return FABAuthManagerLogin.create_token( + body=body, expiration_time_in_sec=conf.getint("api", "auth_jwt_cli_expiration_time") + ) diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/__init__.py b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/__init__.py @@ -0,0 +1,16 @@ +# 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/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/login.py b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/login.py new file mode 100644 index 0000000000000..673cf305faf04 --- /dev/null +++ b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/login.py @@ -0,0 +1,58 @@ +# 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 + +from typing import TYPE_CHECKING, cast + +from starlette import status +from starlette.exceptions import HTTPException + +from airflow.api_fastapi.app import get_auth_manager +from airflow.configuration import conf +from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import LoginBody, LoginResponse +from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager + +if TYPE_CHECKING: + from airflow.providers.fab.auth_manager.models import User + + +class FABAuthManagerLogin: + """Login Service for FABAuthManager.""" + + @classmethod + def create_token( + cls, body: LoginBody, expiration_time_in_sec: int = conf.getint("api", "auth_jwt_expiration_time") + ) -> LoginResponse: + """Create a new token.""" + if not body.username or not body.password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Username and password must be provided" + ) + + auth_manager = cast(FabAuthManager, get_auth_manager()) + user: User = auth_manager.security_manager.find_user(username=body.username) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username") + + if auth_manager.security_manager.check_password(username=body.username, password=body.password): + return LoginResponse( + jwt_token=auth_manager.get_jwt_token( + user=user, expiration_time_in_seconds=expiration_time_in_sec + ) + ) + else: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password") diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py index f761121ebfb8a..c1fb557e17864 100644 --- a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py +++ b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py @@ -185,6 +185,9 @@ def get_cli_commands() -> list[CLICommand]: return commands def get_fastapi_app(self) -> FastAPI | None: + """Get the FastAPI app.""" + from airflow.providers.fab.auth_manager.api_fastapi.routes.login import login_router + flask_app = create_app(enable_plugins=False) app = FastAPI( @@ -196,6 +199,10 @@ def get_fastapi_app(self) -> FastAPI | None: "manager." ), ) + + # Add the login router to the FastAPI app + app.include_router(login_router) + app.mount("/", WSGIMiddleware(flask_app)) return app diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py index 23b23fb51259e..f5045e7c2b294 100644 --- a/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py +++ b/providers/fab/src/airflow/providers/fab/auth_manager/security_manager/override.py @@ -2104,6 +2104,20 @@ def auth_user_ldap(self, username, password): log.error(e) return None + def check_password(self, username, password) -> bool: + """ + Check if the password is correct for the username. + + :param username: the username + :param password: the password + """ + user = self.find_user(username=username) + if user is None: + user = self.find_user(email=username) + if user is None: + return False + return check_password_hash(user.password, password) + def auth_user_db(self, username, password): """ Authenticate user, auth db style. diff --git a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/__init__.py b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/__init__.py @@ -0,0 +1,16 @@ +# 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/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py new file mode 100644 index 0000000000000..86273a0af7451 --- /dev/null +++ b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py @@ -0,0 +1,32 @@ +# 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 pytest +from fastapi.testclient import TestClient + +from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager + + +@pytest.fixture(scope="module") +def fab_auth_manager(): + return FabAuthManager(None) + + +@pytest.fixture(scope="module") +def test_client(fab_auth_manager): + return TestClient(fab_auth_manager.get_fastapi_app()) diff --git a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/__init__.py b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/__init__.py @@ -0,0 +1,16 @@ +# 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/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_login.py b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_login.py new file mode 100644 index 0000000000000..b439cb9823570 --- /dev/null +++ b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_login.py @@ -0,0 +1,52 @@ +# 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 + +from unittest.mock import patch + +import pytest + +from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import LoginBody, LoginResponse + + +@pytest.mark.db_test +class TestLogin: + dummy_login_body = LoginBody(username="dummy", password="dummy") + dummy_token = LoginResponse(jwt_token="DUMMY_TOKEN") + + @patch("airflow.providers.fab.auth_manager.api_fastapi.routes.login.FABAuthManagerLogin") + def test_create_token(self, mock_fab_auth_manager_login, test_client): + mock_fab_auth_manager_login.create_token.return_value = self.dummy_token + + response = test_client.post( + "/token", + json=self.dummy_login_body.model_dump(), + ) + assert response.status_code == 201 + assert response.json()["jwt_token"] == self.dummy_token.jwt_token + + @patch("airflow.providers.fab.auth_manager.api_fastapi.routes.login.FABAuthManagerLogin") + def test_create_token_cli(self, mock_fab_auth_manager_login, test_client): + mock_fab_auth_manager_login.create_token.return_value = LoginResponse(jwt_token="DUMMY_TOKEN") + + response = test_client.post( + "/token/cli", + json=self.dummy_login_body.model_dump(), + ) + assert response.status_code == 201 + assert response.json()["jwt_token"] == self.dummy_token.jwt_token diff --git a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/__init__.py b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/__init__.py new file mode 100644 index 0000000000000..217e5db960782 --- /dev/null +++ b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/__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/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_login.py b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_login.py new file mode 100644 index 0000000000000..fa8f7a862b170 --- /dev/null +++ b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_login.py @@ -0,0 +1,105 @@ +# 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 + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest +from starlette.exceptions import HTTPException + +from airflow.providers.fab.auth_manager.api_fastapi.services.login import FABAuthManagerLogin + +if TYPE_CHECKING: + from airflow.providers.fab.auth_manager.api_fastapi.datamodels.login import LoginResponse + + +@pytest.mark.db_test +@patch("airflow.providers.fab.auth_manager.api_fastapi.services.login.get_auth_manager") +class TestLogin: + def setup_method( + self, + ): + self.dummy_auth_manager = MagicMock() + self.dummy_app_builder = MagicMock() + self.dummy_app = MagicMock() + self.dummy_login_body = MagicMock() + self.dummy_user = MagicMock() + self.dummy_user.password = "dummy" + self.dummy_security_manager = MagicMock() + self.dummy_login_body.username = "dummy" + self.dummy_login_body.password = "dummy" + self.dummy_token = "DUMMY_TOKEN" + + def test_create_token(self, get_auth_manager): + get_auth_manager.return_value = self.dummy_auth_manager + self.dummy_auth_manager.security_manager = self.dummy_security_manager + self.dummy_security_manager.find_user.return_value = self.dummy_user + self.dummy_auth_manager.get_jwt_token.return_value = self.dummy_token + self.dummy_security_manager.check_password.return_value = True + + login_response: LoginResponse = FABAuthManagerLogin.create_token( + body=self.dummy_login_body, + expiration_time_in_sec=1, + ) + assert login_response.jwt_token == self.dummy_token + + def test_create_token_invalid_username(self, get_auth_manager): + get_auth_manager.return_value = self.dummy_auth_manager + self.dummy_auth_manager.security_manager = self.dummy_security_manager + self.dummy_security_manager.find_user.return_value = None + self.dummy_security_manager.check_password.return_value = False + + with pytest.raises(HTTPException) as ex: + FABAuthManagerLogin.create_token( + body=self.dummy_login_body, + expiration_time_in_sec=1, + ) + assert ex.value.status_code == 401 + assert ex.value.detail == "Invalid username" + + def test_create_token_invalid_password(self, get_auth_manager): + get_auth_manager.return_value = self.dummy_auth_manager + self.dummy_auth_manager.security_manager = self.dummy_security_manager + self.dummy_security_manager.find_user.return_value = self.dummy_user + self.dummy_user.password = "invalid_password" + self.dummy_security_manager.check_password.return_value = False + + with pytest.raises(HTTPException) as ex: + FABAuthManagerLogin.create_token( + body=self.dummy_login_body, + expiration_time_in_sec=1, + ) + assert ex.value.status_code == 401 + assert ex.value.detail == "Invalid password" + + def test_create_token_empty_user_password(self, get_auth_manager): + get_auth_manager.return_value = self.dummy_auth_manager + self.dummy_auth_manager.security_manager = self.dummy_security_manager + self.dummy_security_manager.find_user.return_value = self.dummy_user + self.dummy_login_body.username = "" + self.dummy_login_body.password = "" + self.dummy_security_manager.check_password.return_value = False + + with pytest.raises(HTTPException) as ex: + FABAuthManagerLogin.create_token( + body=self.dummy_login_body, + expiration_time_in_sec=1, + ) + assert ex.value.status_code == 400 + assert ex.value.detail == "Username and password must be provided" diff --git a/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py b/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py index 6ba1ccda292cd..0a4132851718e 100644 --- a/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py +++ b/providers/fab/tests/unit/fab/auth_manager/security_manager/test_override.py @@ -52,3 +52,26 @@ def test_load_user_jwt(self, mock_g): sm.load_user.assert_called_once_with("test_identity") assert actual_user is mock_user assert mock_g.user is mock_user + + @mock.patch("airflow.providers.fab.auth_manager.security_manager.override.check_password_hash") + def test_check_password(self, check_password): + sm = EmptySecurityManager() + mock_user = Mock() + sm.find_user = Mock(return_value=mock_user) + check_password.return_value = True + assert sm.check_password("test_user", "test_password") + + @mock.patch("airflow.providers.fab.auth_manager.security_manager.override.check_password_hash") + def test_check_password_user_not_found(self, check_password): + sm = EmptySecurityManager() + sm.find_user = Mock(return_value=None) + check_password.return_value = False + assert not sm.check_password("test_user", "test_password") + + @mock.patch("airflow.providers.fab.auth_manager.security_manager.override.check_password_hash") + def test_check_password_not_match(self, check_password): + sm = EmptySecurityManager() + mock_user = Mock() + sm.find_user = Mock(return_value=mock_user) + check_password.return_value = False + assert not sm.check_password("test_user", "test_password") diff --git a/scripts/in_container/run_update_fastapi_api_spec.py b/scripts/in_container/run_update_fastapi_api_spec.py index 48ccfee65aedd..925590b0ae297 100644 --- a/scripts/in_container/run_update_fastapi_api_spec.py +++ b/scripts/in_container/run_update_fastapi_api_spec.py @@ -23,12 +23,16 @@ from airflow.api_fastapi.app import create_app from airflow.auth.managers.simple.simple_auth_manager import SimpleAuthManager +from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager if TYPE_CHECKING: from fastapi import FastAPI OPENAPI_SPEC_FILE = "airflow/api_fastapi/core_api/openapi/v1-generated.yaml" SIMPLE_AUTH_MANAGER_OPENAPI_SPEC_FILE = "airflow/auth/managers/simple/openapi/v1-generated.yaml" +FAB_AUTH_MANAGER_OPENAPI_SPEC_FILE = ( + "providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/v1-generated.yaml" +) def generate_file(app: FastAPI, file_path: str, prefix: str = ""): @@ -60,9 +64,16 @@ def generate_file(app: FastAPI, file_path: str, prefix: str = ""): # Generate main application openapi spec -generate_file(create_app(), OPENAPI_SPEC_FILE) +generate_file(app=create_app(), file_path=OPENAPI_SPEC_FILE) # Generate simple auth manager openapi spec simple_auth_manager_app = SimpleAuthManager().get_fastapi_app() if simple_auth_manager_app: - generate_file(simple_auth_manager_app, SIMPLE_AUTH_MANAGER_OPENAPI_SPEC_FILE, "/auth") + generate_file( + app=simple_auth_manager_app, file_path=SIMPLE_AUTH_MANAGER_OPENAPI_SPEC_FILE, prefix="/auth" + ) + +# Generate FAB auth manager openapi spec +fab_auth_manager_app = FabAuthManager().get_fastapi_app() +if fab_auth_manager_app: + generate_file(app=fab_auth_manager_app, file_path=FAB_AUTH_MANAGER_OPENAPI_SPEC_FILE) diff --git a/tests/always/test_project_structure.py b/tests/always/test_project_structure.py index ed888981ecde4..ee19e3e2a8005 100644 --- a/tests/always/test_project_structure.py +++ b/tests/always/test_project_structure.py @@ -118,6 +118,7 @@ def test_providers_modules_should_have_tests(self): "providers/edge/tests/unit/edge/worker_api/test_auth.py", "providers/edge/tests/unit/edge/worker_api/test_datamodels.py", "providers/elasticsearch/tests/unit/elasticsearch/test_version_compat.py", + "providers/fab/tests/unit/fab/auth_manager/api_fastapi/datamodels/test_login.py", "providers/fab/tests/unit/fab/migrations/test_env.py", "providers/fab/tests/unit/fab/www/api_connexion/test_exceptions.py", "providers/fab/tests/unit/fab/www/api_connexion/test_parameters.py", diff --git a/tests/auth/managers/simple/services/test_login.py b/tests/auth/managers/simple/services/test_login.py index 59f586b0af5d8..739825c88b4fc 100644 --- a/tests/auth/managers/simple/services/test_login.py +++ b/tests/auth/managers/simple/services/test_login.py @@ -78,4 +78,5 @@ def test_create_token_empty_user_password(self, test_client, json_body): body=LoginBody(username=json_body["username"], password=json_body["password"]), expiration_time_in_sec=1, ) + assert ex.value.status_code == 400 assert "Username and password must be provided" in ex.value.detail