diff --git a/LICENSE b/LICENSE index e273117502d20..bc4f9b1acffdc 100644 --- a/LICENSE +++ b/LICENSE @@ -220,7 +220,6 @@ at licenses/LICENSE-[project].txt. (ALv2 License) hue v4.3.0 (https://github.com/cloudera/hue/) (ALv2 License) jqclock v2.3.0 (https://github.com/JohnRDOrazio/jQuery-Clock-Plugin) (ALv2 License) bootstrap3-typeahead v4.0.2 (https://github.com/bassjobsen/Bootstrap-3-Typeahead) - (ALv2 License) airflow.contrib.auth.backends.github_enterprise_auth ======================================================================== MIT licenses diff --git a/NOTICE b/NOTICE index 982a5390f38e6..49095076650f6 100644 --- a/NOTICE +++ b/NOTICE @@ -6,11 +6,6 @@ Foundation (http://www.apache.org/). ======================================================================= -airflow.contrib.auth.backends.github_enterprise_auth: ------------------------------------------------------ - -* Copyright 2015 Matthew Pelland (matt@pelland.io) - hue: ----- This product contains a modified portion of 'Hue' developed by Cloudera, Inc. diff --git a/airflow/contrib/auth/__init__.py b/airflow/contrib/auth/__init__.py deleted file mode 100644 index 217e5db960782..0000000000000 --- a/airflow/contrib/auth/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# -# 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/contrib/auth/backends/__init__.py b/airflow/contrib/auth/backends/__init__.py deleted file mode 100644 index 217e5db960782..0000000000000 --- a/airflow/contrib/auth/backends/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# -# 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/contrib/auth/backends/github_enterprise_auth.py b/airflow/contrib/auth/backends/github_enterprise_auth.py deleted file mode 100644 index 3ec6bf44746a6..0000000000000 --- a/airflow/contrib/auth/backends/github_enterprise_auth.py +++ /dev/null @@ -1,236 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -import logging - -import flask_login -from flask import redirect, request, url_for -# Need to expose these downstream -# flake8: noqa: F401 -from flask_login import current_user, login_required, login_user, logout_user -from flask_oauthlib.client import OAuth - -from airflow import models -from airflow.configuration import AirflowConfigException, conf -from airflow.utils.session import provide_session - -log = logging.getLogger(__name__) - - -def get_config_param(param): - return str(conf.get('github_enterprise', param)) - - -class GHEUser(models.User): - - def __init__(self, user): - self.user = user - - @property - def is_active(self): - """Required by flask_login""" - return True - - @property - def is_authenticated(self): - """Required by flask_login""" - return True - - @property - def is_anonymous(self): - """Required by flask_login""" - return False - - def get_id(self): - """Returns the current user id as required by flask_login""" - return self.user.get_id() - - def data_profiling(self): - """Provides access to data profiling tools""" - return True - - def is_superuser(self): - """Access all the things""" - return True - - -class AuthenticationError(Exception): - pass - - -class GHEAuthBackend: - - def __init__(self): - self.ghe_host = get_config_param('host') - self.login_manager = flask_login.LoginManager() - self.login_manager.login_view = 'airflow.login' - self.flask_app = None - self.ghe_oauth = None - self.api_url = None - - def ghe_api_route(self, leaf): - if not self.api_url: - self.api_url = ( - 'https://api.github.com' if self.ghe_host == 'github.com' - else '/'.join(['https:/', - self.ghe_host, - 'api', - get_config_param('api_rev')]) - ) - return self.api_url + leaf - - def init_app(self, flask_app): - self.flask_app = flask_app - - self.login_manager.init_app(self.flask_app) - - self.ghe_oauth = OAuth(self.flask_app).remote_app( - 'ghe', - consumer_key=get_config_param('client_id'), - consumer_secret=get_config_param('client_secret'), - # need read:org to get team member list - request_token_params={'scope': 'user:email,read:org'}, - base_url=self.ghe_host, - request_token_url=None, - access_token_method='POST', - access_token_url=''.join(['https://', - self.ghe_host, - '/login/oauth/access_token']), - authorize_url=''.join(['https://', - self.ghe_host, - '/login/oauth/authorize'])) - - self.login_manager.user_loader(self.load_user) - - self.flask_app.add_url_rule(get_config_param('oauth_callback_route'), - 'ghe_oauth_callback', - self.oauth_callback) - - def login(self, request): - log.debug('Redirecting user to GHE login') - return self.ghe_oauth.authorize(callback=url_for( - 'ghe_oauth_callback', - _external=True), - state=request.args.get('next') or request.referrer or None) - - def get_ghe_user_profile_info(self, ghe_token): - resp = self.ghe_oauth.get(self.ghe_api_route('/user'), - token=(ghe_token, '')) - - if not resp or resp.status != 200: - raise AuthenticationError( - 'Failed to fetch user profile, status ({0})'.format( - resp.status if resp else 'None')) - - return resp.data['login'], resp.data['email'] - - def ghe_team_check(self, username, ghe_token): - try: - # the response from ghe returns the id of the team as an integer - try: - allowed_teams = [int(team.strip()) - for team in - get_config_param('allowed_teams').split(',')] - except ValueError: - # this is to deprecate using the string name for a team - raise ValueError( - 'it appears that you are using the string name for a team, ' - 'please use the id number instead') - - except AirflowConfigException: - # No allowed teams defined, let anyone in GHE in. - return True - - # https://developer.github.com/v3/orgs/teams/#list-user-teams - resp = self.ghe_oauth.get(self.ghe_api_route('/user/teams'), - token=(ghe_token, '')) - - if not resp or resp.status != 200: - raise AuthenticationError( - 'Bad response from GHE ({0})'.format( - resp.status if resp else 'None')) - - for team in resp.data: - # mylons: previously this line used to be if team['slug'] in teams - # however, teams are part of organizations. organizations are unique, - # but teams are not therefore 'slug' for a team is not necessarily unique. - # use id instead - if team['id'] in allowed_teams: - return True - - log.debug('Denying access for user "%s", not a member of "%s"', - username, - str(allowed_teams)) - - return False - - @provide_session - def load_user(self, userid, session=None): - if not userid or userid == 'None': - return None - - user = session.query(models.User).filter( - models.User.id == int(userid)).first() - return GHEUser(user) - - @provide_session - def oauth_callback(self, session=None): - log.debug('GHE OAuth callback called') - - next_url = request.args.get('state') or url_for('admin.index') - - resp = self.ghe_oauth.authorized_response() - - try: - if resp is None: - raise AuthenticationError( - 'Null response from GHE, denying access.' - ) - - ghe_token = resp['access_token'] - - username, email = self.get_ghe_user_profile_info(ghe_token) - - if not self.ghe_team_check(username, ghe_token): - return redirect(url_for('airflow.noaccess')) - - except AuthenticationError: - log.exception('') - return redirect(url_for('airflow.noaccess')) - - user = session.query(models.User).filter( - models.User.username == username).first() - - if not user: - user = models.User( - username=username, - email=email, - is_superuser=False) - - session.merge(user) - session.commit() - login_user(GHEUser(user)) - session.commit() - - return redirect(next_url) - - -login_manager = GHEAuthBackend() - - -def login(self, request): - return login_manager.login(request) diff --git a/airflow/contrib/auth/backends/google_auth.py b/airflow/contrib/auth/backends/google_auth.py deleted file mode 100644 index 4e4f6066370e4..0000000000000 --- a/airflow/contrib/auth/backends/google_auth.py +++ /dev/null @@ -1,189 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -import logging - -import flask_login -from flask import redirect, request, url_for -# Need to expose these downstream -# flake8: noqa: F401 -from flask_login import current_user, login_required, login_user, logout_user -from flask_oauthlib.client import OAuth - -from airflow import models -from airflow.configuration import conf -from airflow.utils.session import provide_session - -log = logging.getLogger(__name__) - - -def get_config_param(param): - return str(conf.get('google', param)) - - -class GoogleUser(models.User): - - def __init__(self, user): - self.user = user - - @property - def is_active(self): - """Required by flask_login""" - return True - - @property - def is_authenticated(self): - """Required by flask_login""" - return True - - @property - def is_anonymous(self): - """Required by flask_login""" - return False - - def get_id(self): - """Returns the current user id as required by flask_login""" - return self.user.get_id() - - def data_profiling(self): - """Provides access to data profiling tools""" - return True - - def is_superuser(self): - """Access all the things""" - return True - - -class AuthenticationError(Exception): - pass - - -class GoogleAuthBackend: - - def __init__(self): - # self.google_host = get_config_param('host') - self.login_manager = flask_login.LoginManager() - self.login_manager.login_view = 'airflow.login' - self.flask_app = None - self.google_oauth = None - self.api_rev = None - - def init_app(self, flask_app): - self.flask_app = flask_app - - self.login_manager.init_app(self.flask_app) - - self.google_oauth = OAuth(self.flask_app).remote_app( - 'google', - consumer_key=get_config_param('client_id'), - consumer_secret=get_config_param('client_secret'), - request_token_params={'scope': [ - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/userinfo.email']}, - base_url='https://www.google.com/accounts/', - request_token_url=None, - access_token_method='POST', - access_token_url='https://accounts.google.com/o/oauth2/token', - authorize_url='https://accounts.google.com/o/oauth2/auth') - - self.login_manager.user_loader(self.load_user) - - self.flask_app.add_url_rule(get_config_param('oauth_callback_route'), - 'google_oauth_callback', - self.oauth_callback) - - def login(self, request): - log.debug('Redirecting user to Google login') - return self.google_oauth.authorize(callback=url_for( - 'google_oauth_callback', - _external=True), - state=request.args.get('next') or request.referrer or None) - - def get_google_user_profile_info(self, google_token): - resp = self.google_oauth.get( - 'https://www.googleapis.com/oauth2/v1/userinfo', - token=(google_token, '')) - - if not resp or resp.status != 200: - raise AuthenticationError( - 'Failed to fetch user profile, status ({0})'.format( - resp.status if resp else 'None')) - - return resp.data['name'], resp.data['email'] - - def domain_check(self, email): - domain = email.split('@')[1] - domains = get_config_param('domain').split(',') - if domain in domains: - return True - return False - - @provide_session - def load_user(self, userid, session=None): - if not userid or userid == 'None': - return None - - user = session.query(models.User).filter( - models.User.id == int(userid)).first() - return GoogleUser(user) - - @provide_session - def oauth_callback(self, session=None): - log.debug('Google OAuth callback called') - - next_url = request.args.get('state') or url_for('admin.index') - - resp = self.google_oauth.authorized_response() - - try: - if resp is None: - raise AuthenticationError( - 'Null response from Google, denying access.' - ) - - google_token = resp['access_token'] - - username, email = self.get_google_user_profile_info(google_token) - - if not self.domain_check(email): - return redirect(url_for('airflow.noaccess')) - - except AuthenticationError: - return redirect(url_for('airflow.noaccess')) - - user = session.query(models.User).filter( - models.User.username == username).first() - - if not user: - user = models.User( - username=username, - email=email, - is_superuser=False) - - session.merge(user) - session.commit() - login_user(GoogleUser(user)) - session.commit() - - return redirect(next_url) - - -login_manager = GoogleAuthBackend() - - -def login(self, request): - return login_manager.login(request) diff --git a/airflow/contrib/auth/backends/kerberos_auth.py b/airflow/contrib/auth/backends/kerberos_auth.py deleted file mode 100644 index ee28a7d52b296..0000000000000 --- a/airflow/contrib/auth/backends/kerberos_auth.py +++ /dev/null @@ -1,165 +0,0 @@ -# -# 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. -"""Kerberos authentication module""" -import logging - -import flask_login -# pykerberos should be used as it verifies the KDC, the "kerberos" module does not do so -# and make it possible to spoof the KDC -import kerberos -from flask import flash, redirect, url_for -from flask_login import current_user -from wtforms import Form, PasswordField, StringField -from wtforms.validators import InputRequired - -from airflow import models -from airflow.configuration import conf -from airflow.exceptions import AirflowConfigException -from airflow.security import utils -from airflow.utils.log.logging_mixin import LoggingMixin -from airflow.utils.session import provide_session - -# pylint: disable=c-extension-no-member -LOGIN_MANAGER = flask_login.LoginManager() -LOGIN_MANAGER.login_view = 'airflow.login' # Calls login() below -LOGIN_MANAGER.login_message = None - - -class AuthenticationError(Exception): - """Error raised when authentication error occurs""" - - -class KerberosUser(models.User, LoggingMixin): - """User authenticated with Kerberos""" - def __init__(self, user): - self.user = user - - @staticmethod - def authenticate(username, password): - service_principal = "%s/%s" % ( - conf.get('kerberos', 'principal'), - utils.get_fqdn() - ) - realm = conf.get("kerberos", "default_realm") - - try: - user_realm = conf.get("security", "default_realm") - except AirflowConfigException: - user_realm = realm - - user_principal = utils.principal_from_username(username, user_realm) - - try: - # this is pykerberos specific, verify = True is needed to prevent KDC spoofing - if not kerberos.checkPassword(user_principal, - password, - service_principal, realm, True): - raise AuthenticationError() - except kerberos.KrbError as e: - logging.error( - 'Password validation for user ' - '%s in realm %s failed %s', user_principal, realm, e) - raise AuthenticationError(e) - - return - - @property - def is_active(self): - """Required by flask_login""" - return True - - @property - def is_authenticated(self): - """Required by flask_login""" - return True - - @property - def is_anonymous(self): - """Required by flask_login""" - return False - - def get_id(self): - """Returns the current user id as required by flask_login""" - return self.user.get_id() - - def data_profiling(self): - """Provides access to data profiling tools""" - return True - - def is_superuser(self): - """Access all the things""" - return True - - -@LOGIN_MANAGER.user_loader -@provide_session -def load_user(userid, session=None): - if not userid or userid == 'None': - return None - - user = session.query(models.User).filter(models.User.id == int(userid)).first() - return KerberosUser(user) - - -@provide_session -def login(self, request, session=None): - if current_user.is_authenticated: - flash("You are already logged in") - return redirect(url_for('index')) - - username = None - password = None - - form = LoginForm(request.form) - - if request.method == 'POST' and form.validate(): - username = request.form.get("username") - password = request.form.get("password") - - if not username or not password: - return self.render('airflow/login.html', - title="Airflow - Login", - form=form) - - try: - KerberosUser.authenticate(username, password) - - user = session.query(models.User).filter( - models.User.username == username).first() - - if not user: - user = models.User( - username=username, - is_superuser=False) - - session.merge(user) - session.commit() - flask_login.login_user(KerberosUser(user)) - session.commit() - - return redirect(request.args.get("next") or url_for("admin.index")) - except AuthenticationError: - flash("Incorrect login details") - return self.render('airflow/login.html', - title="Airflow - Login", - form=form) - - -class LoginForm(Form): - username = StringField('Username', [InputRequired()]) - password = PasswordField('Password', [InputRequired()]) diff --git a/airflow/contrib/auth/backends/ldap_auth.py b/airflow/contrib/auth/backends/ldap_auth.py deleted file mode 100644 index defaaad455664..0000000000000 --- a/airflow/contrib/auth/backends/ldap_auth.py +++ /dev/null @@ -1,326 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -import logging -import re -import ssl -import traceback - -import flask_login -from flask import flash, redirect, url_for -from flask_login import current_user, login_required, logout_user # noqa: F401 -from ldap3 import LEVEL, SUBTREE, Connection, Server, Tls, set_config_parameter -from wtforms import Form, PasswordField, StringField -from wtforms.validators import InputRequired - -from airflow import models -from airflow.configuration import AirflowConfigException, conf -from airflow.utils.session import provide_session - -login_manager = flask_login.LoginManager() -login_manager.login_view = 'airflow.login' # Calls login() below -login_manager.login_message = None - -log = logging.getLogger(__name__) - - -class AuthenticationError(Exception): - pass - - -class LdapException(Exception): - pass - - -def get_ldap_connection(dn=None, password=None): - try: - cacert = conf.get("ldap", "cacert") - except AirflowConfigException: - pass - - try: - ignore_malformed_schema = conf.get("ldap", "ignore_malformed_schema") - except AirflowConfigException: - pass - - if ignore_malformed_schema: - set_config_parameter('IGNORE_MALFORMED_SCHEMA', ignore_malformed_schema) - - tls_configuration = Tls(validate=ssl.CERT_REQUIRED, - ca_certs_file=cacert) - - server = Server(conf.get("ldap", "uri"), - use_ssl=True, - tls=tls_configuration) - - conn = Connection(server, dn, password) - - if not conn.bind(): - log.error("Cannot bind to ldap server: %s ", conn.last_error) - raise AuthenticationError("Cannot bind to ldap server") - - return conn - - -def group_contains_user(conn, search_base, group_filter, user_name_attr, username): - search_filter = '(&({0}))'.format(group_filter) - - if not conn.search(search_base, search_filter, attributes=[user_name_attr]): - log.warning("Unable to find group for %s %s", search_base, search_filter) - else: - for entry in conn.entries: - if username.lower() in map(lambda attr: attr.lower(), - getattr(entry, user_name_attr).values): - return True - - return False - - -def groups_user(conn, search_base, user_filter, user_name_att, username): - search_filter = "(&({0})({1}={2}))".format(user_filter, user_name_att, username) - try: - memberof_attr = conf.get("ldap", "group_member_attr") - except Exception: - memberof_attr = "memberOf" - res = conn.search(search_base, search_filter, attributes=[memberof_attr]) - if not res: - log.info("Cannot find user %s", username) - raise AuthenticationError("Invalid username or password") - - if conn.response and memberof_attr not in conn.response[0]["attributes"]: - log.warning("""Missing attribute "%s" when looked-up in Ldap database. - The user does not seem to be a member of a group and therefore won't see any dag - if the option filter_by_owner=True and owner_mode=ldapgroup are set""", - memberof_attr) - return [] - - user_groups = conn.response[0]["attributes"][memberof_attr] - - regex = re.compile("cn=([^,]*).*", re.IGNORECASE) - groups_list = [] - try: - groups_list = [regex.search(i).group(1) for i in user_groups] - except IndexError: - log.warning("Parsing error when retrieving the user's group(s)." - " Check if the user belongs to at least one group" - " or if the user's groups name do not contain special characters") - - return groups_list - - -class LdapUser(models.User): - def __init__(self, user): - self.user = user - self.ldap_groups = [] - - # Load and cache superuser and data_profiler settings. - conn = get_ldap_connection(conf.get("ldap", "bind_user"), - conf.get("ldap", "bind_password")) - - superuser_filter = None - data_profiler_filter = None - try: - superuser_filter = conf.get("ldap", "superuser_filter") - except AirflowConfigException: - pass - - if not superuser_filter: - self.superuser = True - log.debug("Missing configuration for superuser settings or empty. Skipping.") - else: - self.superuser = group_contains_user(conn, - conf.get("ldap", "basedn"), - superuser_filter, - conf.get("ldap", - "user_name_attr"), - user.username) - - try: - data_profiler_filter = conf.get("ldap", "data_profiler_filter") - except AirflowConfigException: - pass - - if not data_profiler_filter: - self.data_profiler = True - log.debug("Missing configuration for data profiler settings or empty. " - "Skipping.") - else: - self.data_profiler = group_contains_user( - conn, - conf.get("ldap", "basedn"), - data_profiler_filter, - conf.get("ldap", - "user_name_attr"), - user.username - ) - - # Load the ldap group(s) a user belongs to - try: - self.ldap_groups = groups_user( - conn, - conf.get("ldap", "basedn"), - conf.get("ldap", "user_filter"), - conf.get("ldap", "user_name_attr"), - user.username - ) - except AirflowConfigException: - log.debug("Missing configuration for ldap settings. Skipping") - - @staticmethod - def try_login(username, password): - conn = get_ldap_connection(conf.get("ldap", "bind_user"), - conf.get("ldap", "bind_password")) - - search_filter = "(&({0})({1}={2}))".format( - conf.get("ldap", "user_filter"), - conf.get("ldap", "user_name_attr"), - username - ) - - search_scope = LEVEL - if conf.has_option("ldap", "search_scope"): - if conf.get("ldap", "search_scope") == "SUBTREE": - search_scope = SUBTREE - else: - search_scope = LEVEL - - # todo: BASE or ONELEVEL? - - res = conn.search(conf.get("ldap", "basedn"), search_filter, search_scope=search_scope) - - # todo: use list or result? - if not res: - log.info("Cannot find user %s", username) - raise AuthenticationError("Invalid username or password") - - entry = conn.response[0] - - conn.unbind() - - if 'dn' not in entry: - # The search filter for the user did not return any values, so an - # invalid user was used for credentials. - raise AuthenticationError("Invalid username or password") - - try: - conn = get_ldap_connection(entry['dn'], password) - except KeyError: - log.error(""" - Unable to parse LDAP structure. If you're using Active Directory - and not specifying an OU, you must set search_scope=SUBTREE in airflow.cfg. - %s - """, traceback.format_exc()) - raise LdapException( - "Could not parse LDAP structure. " - "Try setting search_scope in airflow.cfg, or check logs" - ) - - if not conn: - log.info("Password incorrect for user %s", username) - raise AuthenticationError("Invalid username or password") - - @property - def is_active(self): - """Required by flask_login""" - return True - - @property - def is_authenticated(self): - """Required by flask_login""" - return True - - @property - def is_anonymous(self): - """Required by flask_login""" - return False - - def get_id(self): - """Returns the current user id as required by flask_login""" - return self.user.get_id() - - def data_profiling(self): - """Provides access to data profiling tools""" - return self.data_profiler - - def is_superuser(self): - """Access all the things""" - return self.superuser - - -@login_manager.user_loader -@provide_session -def load_user(userid, session=None): - log.debug("Loading user %s", userid) - if not userid or userid == 'None': - return None - - user = session.query(models.User).filter(models.User.id == int(userid)).first() - return LdapUser(user) - - -@provide_session -def login(self, request, session=None): - if current_user.is_authenticated: - flash("You are already logged in") - return redirect(url_for('admin.index')) - - username = None - password = None - - form = LoginForm(request.form) - - if request.method == 'POST' and form.validate(): - username = request.form.get("username") - password = request.form.get("password") - - if not username or not password: - return self.render('airflow/login.html', - title="Airflow - Login", - form=form) - - try: - LdapUser.try_login(username, password) - log.info("User %s successfully authenticated", username) - - user = session.query(models.User).filter( - models.User.username == username).first() - - if not user: - user = models.User( - username=username, - is_superuser=False) - session.add(user) - - session.commit() - session.merge(user) - flask_login.login_user(LdapUser(user)) - session.commit() - - return redirect(request.args.get("next") or url_for("admin.index")) - except (LdapException, AuthenticationError) as e: - if type(e) == LdapException: - flash(e, "error") - else: - flash("Incorrect login details") - return self.render('airflow/login.html', - title="Airflow - Login", - form=form) - - -class LoginForm(Form): - username = StringField('Username', [InputRequired()]) - password = PasswordField('Password', [InputRequired()]) diff --git a/airflow/contrib/auth/backends/password_auth.py b/airflow/contrib/auth/backends/password_auth.py deleted file mode 100644 index 2c896cb812478..0000000000000 --- a/airflow/contrib/auth/backends/password_auth.py +++ /dev/null @@ -1,221 +0,0 @@ -# -# 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. -"""Password authentication backend""" -import base64 -import logging -from functools import wraps - -import flask_login -from flask import Response, flash, make_response, redirect, url_for -from flask_bcrypt import check_password_hash, generate_password_hash -# noinspection PyUnresolvedReferences -# pylint: disable=unused-import -from flask_login import current_user, login_required, logout_user # noqa: F401 -from sqlalchemy import Column, String -from sqlalchemy.ext.hybrid import hybrid_property -from wtforms import Form, PasswordField, StringField -from wtforms.validators import InputRequired - -from airflow import models -from airflow.utils.session import create_session, provide_session - -LOGIN_MANAGER = flask_login.LoginManager() -LOGIN_MANAGER.login_view = 'airflow.login' # Calls login() below -LOGIN_MANAGER.login_message = None - -log = logging.getLogger(__name__) - - -CLIENT_AUTH = None - - -class AuthenticationError(Exception): - """Error returned on authentication problems""" - - -# pylint: disable=no-member -# noinspection PyUnresolvedReferences -class PasswordUser(models.User): - """Stores user with password""" - _password = Column('password', String(255)) - - def __init__(self, user): - self.user = user - - @hybrid_property - def password(self): - """Returns password for the user""" - return self._password - - @password.setter - def password(self, plaintext): - """Sets password for the user""" - self._password = str(generate_password_hash(plaintext, 12), 'utf-8') - - def authenticate(self, plaintext): - """Authenticates user""" - return check_password_hash(self._password, plaintext) - - @property - def is_active(self): - """Required by flask_login""" - return True - - @property - def is_authenticated(self): - """Required by flask_login""" - return True - - @property - def is_anonymous(self): - """Required by flask_login""" - return False - - def get_id(self): - """Returns the current user id as required by flask_login""" - return str(self.id) - - # pylint: disable=no-self-use - # noinspection PyMethodMayBeStatic - def data_profiling(self): - """Provides access to data profiling tools""" - return True - # pylint: enable=no-self-use - - def is_superuser(self): - """Returns True if user is superuser""" - return hasattr(self, 'user') and self.user.is_superuser() - - -# noinspection PyUnresolvedReferences -@LOGIN_MANAGER.user_loader -@provide_session -def load_user(userid, session=None): - """Loads user from the database""" - log.debug("Loading user %s", userid) - if not userid or userid == 'None': - return None - - user = session.query(models.User).filter(models.User.id == int(userid)).first() - return PasswordUser(user) - - -def authenticate(session, username, password): - """ - Authenticate a PasswordUser with the specified - username/password. - - :param session: An active SQLAlchemy session - :param username: The username - :param password: The password - - :raise AuthenticationError: if an error occurred - :return: a PasswordUser - """ - if not username or not password: - raise AuthenticationError() - - user = session.query(PasswordUser).filter( - PasswordUser.username == username).first() - - if not user: - raise AuthenticationError() - - if not user.authenticate(password): - raise AuthenticationError() - - log.info("User %s successfully authenticated", username) - return user - - -@provide_session -def login(self, request, session=None): - """Logs the user in""" - if current_user.is_authenticated: - flash("You are already logged in") - return redirect(url_for('admin.index')) - - username = None - password = None - - form = LoginForm(request.form) - - if request.method == 'POST' and form.validate(): - username = request.form.get("username") - password = request.form.get("password") - - try: - user = authenticate(session, username, password) - flask_login.login_user(user) - - return redirect(request.args.get("next") or url_for("admin.index")) - except AuthenticationError: - flash("Incorrect login details") - return self.render('airflow/login.html', - title="Airflow - Login", - form=form) - - -# pylint: disable=too-few-public-methods -class LoginForm(Form): - """Form for the user""" - username = StringField('Username', [InputRequired()]) - password = PasswordField('Password', [InputRequired()]) -# pylint: enable=too-few-public-methods - - -def _unauthorized(): - """ - Indicate that authorization is required - :return: - """ - return Response("Unauthorized", 401, {"WWW-Authenticate": "Basic"}) - - -def _forbidden(): - return Response("Forbidden", 403) - - -def init_app(_): - """Initializes backend""" - - -def requires_authentication(function): - """Decorator for functions that require authentication""" - @wraps(function) - def decorated(*args, **kwargs): - from flask import request - - header = request.headers.get("Authorization") - if header: - userpass = ''.join(header.split()[1:]) - username, password = base64.b64decode(userpass).decode("utf-8").split(":", 1) - - with create_session() as session: - try: - authenticate(session, username, password) - - response = function(*args, **kwargs) - response = make_response(response) - return response - - except AuthenticationError: - return _forbidden() - - return _unauthorized() - return decorated diff --git a/docs/security.rst b/docs/security.rst index 1c0789d55c7a1..a1f9609818f35 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -52,63 +52,13 @@ One of the simplest mechanisms for authentication is requiring users to specify Please use command line interface ``airflow users create`` to create accounts, or do that in the UI. - -LDAP -'''' - -To turn on LDAP authentication configure your ``airflow.cfg`` as follows. Please note that the example uses -an encrypted connection to the ldap server as we do not want passwords be readable on the network level. - -Additionally, if you are using Active Directory, and are not explicitly specifying an OU that your users are in, -you will need to change ``search_scope`` to "SUBTREE". - -Valid search_scope options can be found in the `ldap3 Documentation `_ - -.. code-block:: ini - - [webserver] - authenticate = True - auth_backend = airflow.contrib.auth.backends.ldap_auth - - [ldap] - # set a connection without encryption: uri = ldap://: - uri = ldaps://: - user_filter = objectClass=* - # in case of Active Directory you would use: user_name_attr = sAMAccountName - user_name_attr = uid - # group_member_attr should be set accordingly with *_filter - # eg : - # group_member_attr = groupMembership - # superuser_filter = groupMembership=CN=airflow-super-users... - group_member_attr = memberOf - superuser_filter = memberOf=CN=airflow-super-users,OU=Groups,OU=RWC,OU=US,OU=NORAM,DC=example,DC=com - data_profiler_filter = memberOf=CN=airflow-data-profilers,OU=Groups,OU=RWC,OU=US,OU=NORAM,DC=example,DC=com - bind_user = cn=Manager,dc=example,dc=com - bind_password = insecure - basedn = dc=example,dc=com - cacert = /etc/ca/ldap_ca.crt - # Set search_scope to one of them: BASE, LEVEL , SUBTREE - # Set search_scope to SUBTREE if using Active Directory, and not specifying an Organizational Unit - search_scope = LEVEL - - # This option tells ldap3 to ignore schemas that are considered malformed. This sometimes comes up - # when using hosted ldap services. - ignore_malformed_schema = False - -The superuser_filter and data_profiler_filter are optional. If defined, these configurations allow you to specify LDAP groups that users must belong to in order to have superuser (admin) and data-profiler permissions. If undefined, all users will be superusers and data profilers. - -Roll your own +Other Methods ''''''''''''' -Airflow uses ``flask_login`` and -exposes a set of hooks in the ``airflow.default_login`` module. You can -alter the content and make it part of the ``PYTHONPATH`` and configure it as a backend in ``airflow.cfg``. - -.. code-block:: ini +Standing on the shoulder of underlying framework Flask-AppBuilder, Airflow also supports authentication methods like +OAuth, OpenID, LDAP, REMOTE_USER. You can configure in ``webserver_config.py``. For details, please refer to +`Security section of FAB documentation `_. - [webserver] - authenticate = True - auth_backend = mypackage.auth API Authentication ------------------ @@ -122,16 +72,7 @@ Airflow webserver is publicly accessible, and you should probably use the ``deny [api] auth_backend = airflow.api.auth.backend.deny_all -Two "real" methods for authentication are currently supported for the API. - -To enabled Password authentication, set the following in the configuration: - -.. code-block:: ini - - [api] - auth_backend = airflow.contrib.auth.backends.password_auth - -It's usage is similar to the Password Authentication used for the Web interface. +Kerberos authentication is currently supported for the API. To enable Kerberos authentication, set the following in the configuration: @@ -261,100 +202,6 @@ To use kerberos authentication, you must install Airflow with the ``kerberos`` e pip install 'apache-airflow[kerberos]' -OAuth Authentication --------------------- - -GitHub Enterprise (GHE) Authentication -'''''''''''''''''''''''''''''''''''''' - -The GitHub Enterprise authentication backend can be used to authenticate users -against an installation of GitHub Enterprise using OAuth2. You can optionally -specify a team whitelist (composed of slug cased team names) to restrict login -to only members of those teams. - -.. code-block:: ini - - [webserver] - authenticate = True - auth_backend = airflow.contrib.auth.backends.github_enterprise_auth - - [github_enterprise] - host = github.example.com - client_id = oauth_key_from_github_enterprise - client_secret = oauth_secret_from_github_enterprise - oauth_callback_route = /example/ghe_oauth/callback - allowed_teams = 1, 345, 23 - -.. note:: If you do not specify a team whitelist, anyone with a valid account on - your GHE installation will be able to login to Airflow. - -To use GHE authentication, you must install Airflow with the ``github_enterprise`` extras group: - -.. code-block:: bash - - pip install 'apache-airflow[github_enterprise]' - -Setting up GHE Authentication -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -An application must be setup in GHE before you can use the GHE authentication -backend. In order to setup an application: - -1. Navigate to your GHE profile -2. Select 'Applications' from the left hand nav -3. Select the 'Developer Applications' tab -4. Click 'Register new application' -5. Fill in the required information (the 'Authorization callback URL' must be fully qualified e.g. http://airflow.example.com/example/ghe_oauth/callback) -6. Click 'Register application' -7. Copy 'Client ID', 'Client Secret', and your callback route to your ``airflow.cfg`` according to the above example - -Using GHE Authentication with github.com -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -It is possible to use GHE authentication with github.com: - -1. `Create an OAuth App `_ -2. Copy 'Client ID', 'Client Secret' to your airflow.cfg according to the above example -3. Set ``host = github.com`` and ``oauth_callback_route = /oauth/callback`` in ``airflow.cfg`` - -Google Authentication -''''''''''''''''''''' - -The Google authentication backend can be used to authenticate users -against Google using OAuth2. You must specify the email domains to restrict -login, separated with a comma, to only members of those domains. - -.. code-block:: ini - - [webserver] - authenticate = True - auth_backend = airflow.contrib.auth.backends.google_auth - - [google] - client_id = google_client_id - client_secret = google_client_secret - oauth_callback_route = /oauth2callback - domain = example1.com,example2.com - -To use Google authentication, you must install Airflow with the ``google_auth`` extras group: - -.. code-block:: bash - - pip install 'apache-airflow[google_auth]' - -Setting up Google Authentication -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -An application must be setup in the Google API Console before you can use the Google authentication -backend. In order to setup an application: - -1. Navigate to https://console.developers.google.com/apis/ -2. Select 'Credentials' from the left hand nav -3. Click 'Create credentials' and choose 'OAuth client ID' -4. Choose 'Web application' -5. Fill in the required information (the 'Authorized redirect URIs' must be fully qualified e.g. http://airflow.example.com/oauth2callback) -6. Click 'Create' -7. Copy 'Client ID', 'Client Secret', and your redirect URI to your ``airflow.cfg`` according to the above example SSL --- diff --git a/setup.cfg b/setup.cfg index 00396eb40d37d..0a89f24087ccb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,9 +63,6 @@ ignore_errors = True [mypy-airflow._vendor.*] ignore_errors = True -[mypy-airflow.contrib.auth.*] -ignore_errors = True - [isort] line_length=110 combine_as_imports = true