airflow-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From fo...@apache.org
Subject incubator-airflow git commit: [AIRFLOW-1760] Password auth for experimental API
Date Tue, 06 Feb 2018 10:22:27 GMT
Repository: incubator-airflow
Updated Branches:
  refs/heads/master e76cda0ff -> c458a22cf


[AIRFLOW-1760] Password auth for experimental API

Modified the Password authentication to support
HTTP Basic auth

Closes #2730 from NielsZeilemaker/AIRFLOW-1760


Project: http://git-wip-us.apache.org/repos/asf/incubator-airflow/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-airflow/commit/c458a22c
Tree: http://git-wip-us.apache.org/repos/asf/incubator-airflow/tree/c458a22c
Diff: http://git-wip-us.apache.org/repos/asf/incubator-airflow/diff/c458a22c

Branch: refs/heads/master
Commit: c458a22cfdd5847de7d83b470b65e224116aedf9
Parents: e76cda0
Author: niels <niels@zeilemaker.nl>
Authored: Tue Feb 6 11:22:21 2018 +0100
Committer: Fokko Driesprong <fokkodriesprong@godatadriven.com>
Committed: Tue Feb 6 11:22:21 2018 +0100

----------------------------------------------------------------------
 airflow/contrib/auth/backends/password_auth.py  | 360 +++++++++++--------
 docs/api.rst                                    |  13 +-
 .../api/experimental/test_password_endpoints.py |  81 +++++
 3 files changed, 301 insertions(+), 153 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/c458a22c/airflow/contrib/auth/backends/password_auth.py
----------------------------------------------------------------------
diff --git a/airflow/contrib/auth/backends/password_auth.py b/airflow/contrib/auth/backends/password_auth.py
index e380ec4..33f3ae0 100644
--- a/airflow/contrib/auth/backends/password_auth.py
+++ b/airflow/contrib/auth/backends/password_auth.py
@@ -1,151 +1,209 @@
-# -*- coding: utf-8 -*-
-#
-# Licensed 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 unicode_literals
-
-from sys import version_info
-
-import flask_login
-from flask_login import login_required, current_user, logout_user
-from flask import flash
-from wtforms import (
-    Form, PasswordField, StringField)
-from wtforms.validators import InputRequired
-
-from flask import url_for, redirect
-from flask_bcrypt import generate_password_hash, check_password_hash
-
-from sqlalchemy import (
-    Column, String, DateTime)
-from sqlalchemy.ext.hybrid import hybrid_property
-
-from airflow import settings
-from airflow import models
-from airflow.utils.db import provide_session
-from airflow.utils.log.logging_mixin import LoggingMixin
-
-login_manager = flask_login.LoginManager()
-login_manager.login_view = 'airflow.login'  # Calls login() below
-login_manager.login_message = None
-
-log = LoggingMixin().log
-PY3 = version_info[0] == 3
-
-
-class AuthenticationError(Exception):
-    pass
-
-
-class PasswordUser(models.User):
-    _password = Column('password', String(255))
-
-    def __init__(self, user):
-        self.user = user
-
-    @hybrid_property
-    def password(self):
-        return self._password
-
-    @password.setter
-    def _set_password(self, plaintext):
-        self._password = generate_password_hash(plaintext, 12)
-        if PY3:
-            self._password = str(self._password, 'utf-8')
-
-    def authenticate(self, plaintext):
-        return check_password_hash(self._password, plaintext)
-
-    def is_active(self):
-        '''Required by flask_login'''
-        return True
-
-    def is_authenticated(self):
-        '''Required by flask_login'''
-        return True
-
-    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)
-
-    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):
-    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)
-
-
-@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:
-        user = session.query(PasswordUser).filter(
-            PasswordUser.username == username).first()
-
-        if not user:
-            session.close()
-            raise AuthenticationError()
-
-        if not user.authenticate(password):
-            session.close()
-            raise AuthenticationError()
-        log.info("User %s successfully authenticated", username)
-
-        flask_login.login_user(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()])
+# -*- coding: utf-8 -*-
+#
+# Licensed 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 unicode_literals
+
+from sys import version_info
+
+import base64
+import flask_login
+from flask_login import current_user
+from flask import flash, Response
+from wtforms import Form, PasswordField, StringField
+from wtforms.validators import InputRequired
+from functools import wraps
+
+from flask import url_for, redirect, make_response
+from flask_bcrypt import generate_password_hash, check_password_hash
+
+from sqlalchemy import Column, String
+from sqlalchemy.ext.hybrid import hybrid_property
+
+from airflow import settings
+from airflow import models
+from airflow.utils.db import provide_session
+from airflow.utils.log.logging_mixin import LoggingMixin
+
+login_manager = flask_login.LoginManager()
+login_manager.login_view = 'airflow.login'  # Calls login() below
+login_manager.login_message = None
+
+log = LoggingMixin().log
+PY3 = version_info[0] == 3
+
+
+class AuthenticationError(Exception):
+    pass
+
+
+class PasswordUser(models.User):
+    _password = Column('password', String(255))
+
+    def __init__(self, user):
+        self.user = user
+
+    @hybrid_property
+    def password(self):
+        return self._password
+
+    @password.setter
+    def _set_password(self, plaintext):
+        self._password = generate_password_hash(plaintext, 12)
+        if PY3:
+            self._password = str(self._password, 'utf-8')
+
+    def authenticate(self, plaintext):
+        return check_password_hash(self._password, plaintext)
+
+    def is_active(self):
+        '''Required by flask_login'''
+        return True
+
+    def is_authenticated(self):
+        '''Required by flask_login'''
+        return True
+
+    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)
+
+    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):
+    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):
+    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)
+    finally:
+        session.commit()
+        session.close()
+
+
+class LoginForm(Form):
+    username = StringField('Username', [InputRequired()])
+    password = PasswordField('Password', [InputRequired()])
+
+
+def _unauthorized():
+    """
+    Indicate that authorization is required
+    :return:
+    """
+    return Response("Unauthorized", 401, {"WWW-Authenticate": "Basic"})
+
+
+def _forbidden():
+    return Response("Forbidden", 403)
+
+
+def init_app(app):
+    pass
+
+
+def requires_authentication(function):
+    @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)
+
+            session = settings.Session()
+            try:
+                authenticate(session, username, password)
+
+                response = function(*args, **kwargs)
+                response = make_response(response)
+                return response
+
+            except AuthenticationError:
+                return _forbidden()
+
+            finally:
+                session.commit()
+                session.close()
+        return _unauthorized()
+    return decorated

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/c458a22c/docs/api.rst
----------------------------------------------------------------------
diff --git a/docs/api.rst b/docs/api.rst
index 856ec9e..4ea19c8 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -37,9 +37,18 @@ Airflow webserver is publicly accessible, and you should probably use the
deny a
     [api]
     auth_backend = airflow.api.auth.backend.deny_all
 
+Two "real" methods for authentication are currently supported for the API.
 
-Kerberos is the only "real" authentication mechanism currently supported for the API. To
enable
-this set the following in the configuration:
+To enabled Password authentication, set the following in the configuration:
+
+.. code-block:: bash
+
+    [api]
+    auth_backend = airflow.contrib.auth.backends.password_auth
+
+It's usage is similar to the Password Authentication used for the Web interface.
+
+To enable Kerberos authentication, set the following in the configuration:
 
 .. code-block:: ini
 

http://git-wip-us.apache.org/repos/asf/incubator-airflow/blob/c458a22c/tests/www/api/experimental/test_password_endpoints.py
----------------------------------------------------------------------
diff --git a/tests/www/api/experimental/test_password_endpoints.py b/tests/www/api/experimental/test_password_endpoints.py
new file mode 100644
index 0000000..2c2cc7f
--- /dev/null
+++ b/tests/www/api/experimental/test_password_endpoints.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+#
+# Licensed 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 json
+import unittest
+
+from datetime import datetime
+
+from airflow import models
+from airflow import configuration
+from airflow.www import app as application
+from airflow.settings import Session
+from airflow.contrib.auth.backends.password_auth import PasswordUser
+
+try:
+    from ConfigParser import DuplicateSectionError
+except ImportError:
+    from configparser import DuplicateSectionError
+
+
+class ApiPasswordTests(unittest.TestCase):
+    def setUp(self):
+        configuration.load_test_config()
+        try:
+            configuration.conf.add_section("api")
+        except DuplicateSectionError:
+            pass
+
+        configuration.conf.set("api",
+                               "auth_backend",
+                               "airflow.contrib.auth.backends.password_auth")
+
+        self.app = application.create_app(testing=True)
+
+        session = Session()
+        user = models.User()
+        password_user = PasswordUser(user)
+        password_user.username = 'hello'
+        password_user.password = 'world'
+        session.add(password_user)
+        session.commit()
+        session.close()
+
+    def test_authorized(self):
+        with self.app.test_client() as c:
+            url_template = '/api/experimental/dags/{}/dag_runs'
+            response = c.post(
+                url_template.format('example_bash_operator'),
+                data=json.dumps(dict(run_id='my_run' + datetime.now().isoformat())),
+                content_type="application/json",
+                headers={'Authorization': 'Basic aGVsbG86d29ybGQ='}  # hello:world
+            )
+            self.assertEqual(200, response.status_code)
+
+    def test_unauthorized(self):
+        with self.app.test_client() as c:
+            url_template = '/api/experimental/dags/{}/dag_runs'
+            response = c.post(
+                url_template.format('example_bash_operator'),
+                data=json.dumps(dict(run_id='my_run' + datetime.now().isoformat())),
+                content_type="application/json"
+            )
+
+            self.assertEqual(401, response.status_code)
+
+    def tearDown(self):
+        session = Session()
+        session.query(models.User).delete()
+        session.commit()
+        session.close()


Mime
View raw message