dpgaspar / Flask-AppBuilder

Simple and rapid application development framework, built on top of Flask. includes detailed security, auto CRUD generation for your models, google charts and much more. Demo (login with guest/welcome) - http://flaskappbuilder.pythonanywhere.com/
BSD 3-Clause "New" or "Revised" License
4.7k stars 1.36k forks source link

Azure OAuth CSRF State Not Equal Error #1957

Closed ahipp13 closed 12 months ago

ahipp13 commented 1 year ago

If you'd like to report a bug in Flask-Appbuilder, fill out the template below. Provide any extra information that may be useful

Responsible disclosure: We want to keep Flask-AppBuilder safe for everyone. If you've discovered a security vulnerability please report to danielvazgaspar@gmail.com.

Environment

Flask-Appbuilder version:

pip freeze output: Flask-Appbuilder version==4.1.4

Describe the expected results

We are currently running Airflow 2.4.3 on Kubernetes with the Airflow Community helm chart version 8.6.1 (located here: https://github.com/airflow-helm/charts).

We have enabled Azure OAuth authentication for our webserver. This should bring up our webserver with an "login with azure" button and we should be able to click it and log in just fine. This is our webserver_config that we are using:

from flask_appbuilder.security.manager import AUTH_OAUTH
from airflow.www.security import AirflowSecurityManager
import logging
from typing import Dict, Any, List, Union
import os
import sys

#Add this as a module to pythons path
sys.path.append('/opt/airflow')

log = logging.getLogger(__name__)
log.setLevel(os.getenv("AIRFLOW__LOGGING__FAB_LOGGING_LEVEL", "DEBUG"))

class AzureCustomSecurity(AirflowSecurityManager):
    # In this example, the oauth provider == 'azure'.
    # If you ever want to support other providers, see how it is done here:
    # https://github.com/dpgaspar/Flask-AppBuilder/blob/master/flask_appbuilder/security/manager.py#L550
    def get_oauth_user_info(self, provider, resp):
        # Creates the user info payload from Azure.
        # The user previously allowed your app to act on their behalf,
        #   so now we can query the user and teams endpoints for their data.
        # Username and team membership are added to the payload and returned to FAB.
        if provider == "azure":
            log.debug("Azure response received : {0}".format(resp))
            id_token = resp["id_token"]
            log.debug(str(id_token))
            me = self._azure_jwt_token_parse(id_token)
            log.debug("Parse JWT token : {0}".format(me))
            return {
                "name": me.get("name", ""),
                "email": me["upn"],
                "first_name": me.get("given_name", ""),
                "last_name": me.get("family_name", ""),
                "id": me["oid"],
                "username": me["oid"],
                "role_keys": me.get("roles", []),
            }

# Adding this in because if not the redirect url will start with http and we want https
os.environ["AIRFLOW__WEBSERVER__ENABLE_PROXY_FIX"] = "True"
WTF_CSRF_ENABLED = False
CSRF_ENABLED = False
AUTH_TYPE = AUTH_OAUTH
AUTH_ROLES_SYNC_AT_LOGIN = True  # Checks roles on every login
# Make sure to replace this with the path to your security manager class
FAB_SECURITY_MANAGER_CLASS = "webserver_config.AzureCustomSecurity"
# a mapping from the values of `userinfo["role_keys"]` to a list of FAB roles
AUTH_ROLES_MAPPING = {
    "airflow_dev_admin": ["Admin"],
    "airflow_dev_op": ["Op"],
    "airflow_dev_user": ["User"],
    "airflow_dev_viewer": ["Viewer"]
    }
# force users to re-auth after 30min of inactivity (to keep roles in sync)
PERMANENT_SESSION_LIFETIME = 1800
# If you wish, you can add multiple OAuth providers.
OAUTH_PROVIDERS = [
    {
        "name": "azure",
        "icon": "fa-windows",
        "token_key": "access_token",
        "remote_app": {
            "client_id": "CLIENT_ID",
            "client_secret": 'AZURE_DEV_CLIENT_SECRET',
            "api_base_url": "https://login.microsoftonline.com/TENANT_ID",
            "request_token_url": None,
            'request_token_params': {
                'scope': 'openid email profile'
            },
            "access_token_url": "https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/token",
            "access_token_params": {
                'scope': 'openid email profile'
            },
            "authorize_url": "https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/authorize",
            "authorize_params": {
                'scope': 'openid email profile',
            },
            'jwks_uri':'https://login.microsoftonline.com/common/discovery/v2.0/keys',
        },
    },
]

Describe the actual results

Instead, we are getting this error after we click the Azure button:

[2022-11-28 22:04:58,744] {views.py:659} ERROR - Error authorizing OAuth access token: mismatching_state: CSRF Warning! State not equal in request and response. airflow-web [2022-11-28 22:04:58,744] {views.py:659} ERROR - Error authorizing OAuth access token: mismatching_state: CSRF Warning! State not equal in request and response.

Steps to reproduce

Running Airflow 2.4.3 on Kubernetes with the Airflow Community helm chart version 8.6.1 and using the webserver_config file like above. When the webserver is running, you click on the "login to azure" button.

Additional Comments

I already posted an issue like this in the Airflow repo, and they said this could more then likely be a Flask problem, which is why I am making this issue here. If any other information is needed please let me know

dpgaspar commented 1 year ago

Have you checked this: https://stackoverflow.com/questions/61922045/mismatchingstateerror-mismatching-state-csrf-warning-state-not-equal-in-reque

also what Authlib version are you using?

ahipp13 commented 1 year ago

Yes, I have checked that stack overflow article. Our secret key is being set in our helm chart for Airflow and this error still persists.

As far as versions, I will give you what we were using before when it worked and what we are using now that is not working:

The versions we had when this was working:

Airflow==2.2.5 Authlib==0.15.5 Flask-AppBuilder==3.4.5 Flask-Babel==2.0.0 Flask-Caching==1.10.1 Flask-JWT-Extended==3.25.1 Flask-Login==0.4.1 Flask-OpenID==1.3.0 Flask-SQLAlchemy==2.5.1 Flask-Session==0.4.0 Flask-WTF==0.14.3 Flask==1.1.2

The versions we have now:

Airflow==2.4.3 Authlib==1.1.0 Flask-AppBuilder==4.1.4 Flask-Babel==2.0.0 Flask-Caching==2.0.1 Flask-JWT-Extended==4.4.4 Flask-Login==0.6.2 Flask-SQLAlchemy==2.5.1 Flask-Session==0.4.0 Flask-WTF==1.0.1 Flask==2.2.2

ahipp13 commented 1 year ago

You can look for additional info on this issue here as well: https://github.com/lepture/authlib/issues/518

ahipp13 commented 1 year ago

@dpgaspar to give an update for you and everybody else with FAB, I have did additional debugging and want to share the results:

It looks like when I try to log in, it is failing in the file flask_appbuilder/security/views.py at line 657: log.error("Error authorizing OAuth access token: {0}".format(e)). It hits this line because it is in a try block. This is the function it is trying: resp = self.appbuilder.sm.oauth_remotes[provider].authorize_access_token()

Following this function, I found it leads to this file: authlib/integrations/flask_client/apps.py. It is failing at line 103 which is:

params = self._format_state_params(state_data, params)

Looking into this further, it looks like this calls the _format_state_params method in the file authlib/integrations/base_client/sync_app.py, and in this method it is going in the if statement, which is: if state_data is None

so, in the apps.py file when it does the line: state_data = self.framework.get_state_data(session, params.get('state')) my state is coming in as none. But, when I look at the network debugger tool in chrome, it looks like state is getting added to the urls being sent.

Digging into this even more, in the authlib/integrations/flask_client/apps.py file, line 101: state_data = self.framework.get_state_data(session, params.get('state')) is how it is getting the state_data. I have printed out the line "params.get('state')", and this returns the correct state value.

Looking into this more, it looks like this leads to this file: authlib/integrations/base_client/framework_integration.py. In this file, it sets up a key at line 34: key = f'state{self.name}_{state}' and tries to get the session data with this key: value = session.get(key). But, it is returning "None". I have also tried to print out he name of the session, and it prints out "None" as well. When I just try to print out the session in general, this is what is printed: <SqlAlchemySession {'_permanent': True, '_fresh': False}>

So, it looks like the session is being created, but there is nothing in it. So my question is, why would the session be empty, and how do I debug and fix this?

Also, here is a picture of my debugging output: image

ahipp13 commented 1 year ago

Also, if it helps, I have found where the app gets created in Airflow:

#
# 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 warnings
from datetime import timedelta
from tempfile import gettempdir

from flask import Flask
from flask_appbuilder import SQLA
from flask_caching import Cache
from flask_wtf.csrf import CSRFProtect
from sqlalchemy.engine.url import make_url

from airflow import settings
from airflow.configuration import conf
from airflow.exceptions import AirflowConfigException, RemovedInAirflow3Warning
from airflow.logging_config import configure_logging
from airflow.models import import_all_models
from airflow.utils.json import AirflowJsonProvider
from airflow.www.extensions.init_appbuilder import init_appbuilder
from airflow.www.extensions.init_appbuilder_links import init_appbuilder_links
from airflow.www.extensions.init_dagbag import init_dagbag
from airflow.www.extensions.init_jinja_globals import init_jinja_globals
from airflow.www.extensions.init_manifest_files import configure_manifest_files
from airflow.www.extensions.init_robots import init_robots
from airflow.www.extensions.init_security import (
    init_api_experimental_auth,
    init_check_user_active,
    init_xframe_protection,
)
from airflow.www.extensions.init_session import init_airflow_session_interface
from airflow.www.extensions.init_views import (
    init_api_connexion,
    init_api_experimental,
    init_appbuilder_views,
    init_connection_form,
    init_error_handlers,
    init_flash_views,
    init_plugins,
)
from airflow.www.extensions.init_wsgi_middlewares import init_wsgi_middleware

app: Flask | None = None

# Initializes at the module level, so plugins can access it.
# See: /docs/plugins.rst
csrf = CSRFProtect()

def sync_appbuilder_roles(flask_app):
    """Sync appbuilder roles to DB"""
    # Garbage collect old permissions/views after they have been modified.
    # Otherwise, when the name of a view or menu is changed, the framework
    # will add the new Views and Menus names to the backend, but will not
    # delete the old ones.
    if conf.getboolean('webserver', 'UPDATE_FAB_PERMS'):
        flask_app.appbuilder.sm.sync_roles()

def create_app(config=None, testing=False):
    """Create a new instance of Airflow WWW app"""
    flask_app = Flask(__name__)
    flask_app.secret_key = conf.get('webserver', 'SECRET_KEY')

    flask_app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=settings.get_session_lifetime_config())
    flask_app.config.from_pyfile(settings.WEBSERVER_CONFIG, silent=True)
    flask_app.config['APP_NAME'] = conf.get(section="webserver", key="instance_name", fallback="Airflow")
    flask_app.config['TESTING'] = testing
    flask_app.config['SQLALCHEMY_DATABASE_URI'] = conf.get('database', 'SQL_ALCHEMY_CONN')

    url = make_url(flask_app.config['SQLALCHEMY_DATABASE_URI'])
    if url.drivername == 'sqlite' and url.database and not url.database.startswith('/'):
        raise AirflowConfigException(
            f'Cannot use relative path: `{conf.get("database", "SQL_ALCHEMY_CONN")}` to connect to sqlite. '
            'Please use absolute path such as `sqlite:////tmp/airflow.db`.'
        )

    flask_app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

    flask_app.config['SESSION_COOKIE_HTTPONLY'] = True
    flask_app.config['SESSION_COOKIE_SECURE'] = conf.getboolean('webserver', 'COOKIE_SECURE')

    cookie_samesite_config = conf.get('webserver', 'COOKIE_SAMESITE')
    if cookie_samesite_config == "":
        warnings.warn(
            "Old deprecated value found for `cookie_samesite` option in `[webserver]` section. "
            "Using `Lax` instead. Change the value to `Lax` in airflow.cfg to remove this warning.",
            RemovedInAirflow3Warning,
        )
        cookie_samesite_config = "Lax"
    flask_app.config['SESSION_COOKIE_SAMESITE'] = cookie_samesite_config

    if config:
        flask_app.config.from_mapping(config)

    if 'SQLALCHEMY_ENGINE_OPTIONS' not in flask_app.config:
        flask_app.config['SQLALCHEMY_ENGINE_OPTIONS'] = settings.prepare_engine_args()

    # Configure the JSON encoder used by `|tojson` filter from Flask
    flask_app.json_provider_class = AirflowJsonProvider
    flask_app.json = AirflowJsonProvider(flask_app)

    csrf.init_app(flask_app)

    init_wsgi_middleware(flask_app)

    db = SQLA()
    db.session = settings.Session
    db.init_app(flask_app)

    init_dagbag(flask_app)

    init_api_experimental_auth(flask_app)

    init_robots(flask_app)

    cache_config = {'CACHE_TYPE': 'flask_caching.backends.filesystem', 'CACHE_DIR': gettempdir()}
    Cache(app=flask_app, config=cache_config)

    init_flash_views(flask_app)

    configure_logging()
    configure_manifest_files(flask_app)

    import_all_models()

    with flask_app.app_context():
        init_appbuilder(flask_app)

        init_appbuilder_views(flask_app)
        init_appbuilder_links(flask_app)
        init_plugins(flask_app)
        init_connection_form()
        init_error_handlers(flask_app)
        init_api_connexion(flask_app)
        init_api_experimental(flask_app)

        sync_appbuilder_roles(flask_app)

        init_jinja_globals(flask_app)
        init_xframe_protection(flask_app)
        init_airflow_session_interface(flask_app)
        init_check_user_active(flask_app)
    return flask_app

def cached_app(config=None, testing=False):
    """Return cached instance of Airflow WWW app"""
    global app
    if not app:
        app = create_app(config=config, testing=testing)
    return app

def purge_cached_app():
    """Removes the cached version of the app in global state."""
    global app
    app = None
ahipp13 commented 1 year ago

@dpgaspar I have did more debugging, and am back with another update to provide more information on this issue.

Looks like I figured out what is happening but am unsure on how to fix. I have put in a ton of debugging statements to follow the flow of code to see what was happening in the background. But, in interest of keeping this update short, I will just summarize what I found and what I think is happening.

We start in flask_appbuilder/security/views.py in the "def login(self, provider: Optional[str] = None) -> WerkzeugResponse:" function under the "AuthOAuthView(AuthView) class. This function returns a call to another function, "authorize_redirect".

The function" authorize_redirect" is found in authlib/integrations/flask_client/apps.py at line 39. This function does a lot of things, but the most important is at the end when it calls "save_authorize_data". "save_authorize_data" is in the same file, and found at line 32. This function calls a function called "set_state_data". This leads us to authlib/integrations/base_client/framework_integration.py, where it sets the session data and returns.

Before the function "Authorize_redirect" returns back to "views.py", I printed out the session to see what it looked like. This is what is printed: image

Clearly, you can see here that session has the correct state. So it returns the redirect url, and according to this in the return statement of the "login" function: "redirect_uri=url_for( ".oauth_authorized", provider=provider _external=True)" my guess is that the next place its going is the "oauth_authorized" function within the "views.py" file. So, I put print statements at the top of this file to print out the session, and this is what I get: image

So, somehow the redirect is causing the session to lose all its data…

I will be investigating on this more Tuesday, but I hope you can help provide insight on how to fix.

ahipp13 commented 1 year ago

@dpgaspar I have been stuck on finding out why the redirect is causing the session to lose all of its data, any help on how to debug and fix this?

ahipp13 commented 1 year ago

@dpgaspar I have finally found what the source of this problem was, as well as the solution.

Doing more debugging, I found that the problem with the session was coming from the webserver_config.py file that I had created. So, what I did was started with as barebones of a Oauth Webserver_config.py file as I could, and kept adding lines until one of them screwed up the session. In doing this I found that it was this line:

PERMANENT_SESSION_LIFETIME = 1800

That was causing the session to not persist. I am not too sure as to why. The reason I had put this in my webserver_config.py file in the first place is because it is in the FAB documentation(https://flask-appbuilder.readthedocs.io/en/latest/security.html#), but in further researching I found that it is already being set in the creation of the flask app by Airflow and is an configuration option: AIRFLOWWEBSERVERSESSION_LIFETIME_MINUTES

Now why this worked in Airflow version 2.2.5 and not in Airflow 2.4.3, I am not for sure.

Also through my debugging I found out that FAB natively supports Azure now, so a custom security class and user info handler function is not needed.

So, the solution to my problem was to start using thie webserver_config.py file down below.

from __future__ import annotations
import os
from airflow.www.fab_security.manager import AUTH_OAUTH
# Default Auth Type
# from airflow.www.fab_security.manager import AUTH_DB

basedir = os.path.abspath(os.path.dirname(__file__))

# Flask-WTF flag for CSRF
WTF_CSRF_ENABLED = True

# ----------------------------------------------------
# AUTHENTICATION CONFIG
# ----------------------------------------------------
# For details on how to set up each of the following authentication, see
# http://flask-appbuilder.readthedocs.io/en/latest/security.html# authentication-methods
# for details.

# The authentication type
AUTH_TYPE = AUTH_OAUTH

# registration configs
AUTH_USER_REGISTRATION = True  # allow users who are not already in the FAB DB
AUTH_USER_REGISTRATION_ROLE = "Public"  # this role will be given in addition to any AUTH_ROLES_MAPPING

# Specifying Oauth Providers
OAUTH_PROVIDERS = [
    {
        "name": "azure",
        "icon": "fa-windows",
        "token_key": "access_token",
        "remote_app": {
            "client_id": "APPLICATION_CLIENT_ID",
            "client_secret": "AZURE_DEV_CLIENT_SECRET",
            "api_base_url": "https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2",
            "client_kwargs": {
                "scope": "User.read name preferred_username email profile upn",
                "resource": "APPLICATION_CLIENT_ID",
            },
            "request_token_url": None,
            "access_token_url": "https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/token",
            "authorize_url": "https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/authorize",
            'jwks_uri':'https://login.microsoftonline.com/common/discovery/v2.0/keys',
        },
    },
]

# Adding this in because if not the redirect url will start with http and we want https
os.environ["AIRFLOW__WEBSERVER__ENABLE_PROXY_FIX"] = "True"

#This maps our roles set in Azure to the roles in Airflow
AUTH_ROLES_MAPPING = {
    "airflow_dev_admin": ["Admin"],
    "airflow_dev_op": ["Op"],
    "airflow_dev_user": ["User"],
    "airflow_dev_viewer": ["Viewer"]
    }

# # if we should replace ALL the user's roles each login, or only on registration
AUTH_ROLES_SYNC_AT_LOGIN = True
ahipp13 commented 1 year ago

@dpgaspar interesting note I want to add that should be reported as bug either on your side or Airflow's side. Looks like the problem is with the PERMANENT_SESSION_LIFETIME variable in general. I just tried to set this value to 30 minutes in my helm chart like this:

AIRFLOWWEBSERVERSESSION_LIFETIME_MINUTES: "30"

And the CSRF state error instantly came back. After taking this out, it worked again. So this should be reported as a bug.

Narender-007 commented 1 year ago

when i am using azure authentication in flask app builder getting this error :

Error returning OAuth user info: %s 'upn' 2023-08-01 12:04:40,989:ERROR:flask_appbuilder.security.views:Error returning OAuth user info: 'upn'

    if provider == "azure":
        log.debug("Azure response received : {0}".format(resp))
        id_token = resp["id_token"]
        log.debug(str(id_token))
        me = self._azure_jwt_token_parse(id_token)
        log.debug("Parse JWT token : {0}".format(me))
        return {
            "name": me.get("name", ""),
            "email": me["upn"],
            "first_name": me.get("given_name", ""),
            "last_name": me.get("family_name", ""),
            "id": me["oid"],
            "username": me["oid"],
            "role_keys": me.get("roles", []),
        }

i have got jwt token credentials are verified but getting UPN key error

how can i resolve it

dpgaspar commented 12 months ago

when i am using azure authentication in flask app builder getting this error :

Error returning OAuth user info: %s 'upn' 2023-08-01 12:04:40,989:ERROR:flask_appbuilder.security.views:Error returning OAuth user info: 'upn'

    if provider == "azure":
        log.debug("Azure response received : {0}".format(resp))
        id_token = resp["id_token"]
        log.debug(str(id_token))
        me = self._azure_jwt_token_parse(id_token)
        log.debug("Parse JWT token : {0}".format(me))
        return {
            "name": me.get("name", ""),
            "email": me["upn"],
            "first_name": me.get("given_name", ""),
            "last_name": me.get("family_name", ""),
            "id": me["oid"],
            "username": me["oid"],
            "role_keys": me.get("roles", []),
        }

i have got jwt token credentials are verified but getting UPN key error

how can i resolve it

fixed on #2121