Skip to content
Snippets Groups Projects
Commit 1cd31018 authored by Marco Martorana's avatar Marco Martorana
Browse files

Add

- UI customization (urbanite.css) for controller
- BE python classes for API auth with Keycloak
- Fix for logout (IDM)
parent 390e4c30
No related branches found
No related tags found
No related merge requests found
Showing with 6464 additions and 15 deletions
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Basic authentication backend"""
from functools import wraps
from typing import Callable, Optional, Tuple, TypeVar, Union, cast
from flask import Response, current_app, request
from flask_appbuilder.const import AUTH_LDAP
from flask_appbuilder.security.sqla.models import User
from flask_login import login_user
from requests.auth import AuthBase
CLIENT_AUTH: Optional[Union[Tuple[str, str], AuthBase]] = None
def init_app(_):
"""Initializes authentication backend"""
T = TypeVar("T", bound=Callable) # pylint: disable=invalid-name
def auth_current_user() -> Optional[User]:
"""Authenticate and set current user if Authorization header exists"""
auth = request.authorization
if auth is None or not auth.username or not auth.password:
return None
ab_security_manager = current_app.appbuilder.sm
user = None
if ab_security_manager.auth_type == AUTH_LDAP:
user = ab_security_manager.auth_user_ldap(auth.username, auth.password)
if user is None:
user = ab_security_manager.auth_user_db(auth.username, auth.password)
if user is not None:
login_user(user, remember=False)
return user
def requires_authentication(function: T):
"""Decorator for functions that require authentication"""
@wraps(function)
def decorated(*args, **kwargs):
if auth_current_user() is not None:
return function(*args, **kwargs)
else:
return Response("Unauthorized", 401, {"WWW-Authenticate": "Basic"})
return cast(T, decorated)
#
# 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.
"""Default authentication backend - everything is allowed"""
from functools import wraps
from typing import Callable, Optional, Tuple, TypeVar, Union, cast
from requests.auth import AuthBase
CLIENT_AUTH: Optional[Union[Tuple[str, str], AuthBase]] = None
def init_app(_):
"""Initializes authentication backend"""
T = TypeVar("T", bound=Callable) # pylint: disable=invalid-name
def requires_authentication(function: T):
"""Decorator for functions that require authentication"""
@wraps(function)
def decorated(*args, **kwargs):
return function(*args, **kwargs)
return cast(T, decorated)
#
# 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.
"""Authentication backend that denies all requests"""
from functools import wraps
from typing import Callable, Optional, Tuple, TypeVar, Union, cast
from flask import Response
from requests.auth import AuthBase
CLIENT_AUTH: Optional[Union[Tuple[str, str], AuthBase]] = None
def init_app(_):
"""Initializes authentication"""
T = TypeVar("T", bound=Callable) # pylint: disable=invalid-name
def requires_authentication(function: T):
"""Decorator for functions that require authentication"""
@wraps(function)
def decorated(*args, **kwargs): # pylint: disable=unused-argument
return Response("Forbidden", 403)
return cast(T, decorated)
# 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.
#
# Copyright (c) 2013, Michael Komitee
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without modification,
# are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""Kerberos authentication module"""
import logging
import os
from functools import wraps
from socket import getfqdn
from typing import Callable, Optional, Tuple, TypeVar, Union, cast
import kerberos
from flask import Response, _request_ctx_stack as stack, g, make_response, request # type: ignore
from requests.auth import AuthBase
from requests_kerberos import HTTPKerberosAuth
from airflow.configuration import conf
log = logging.getLogger(__name__)
# pylint: disable=c-extension-no-member
CLIENT_AUTH: Optional[Union[Tuple[str, str], AuthBase]] = HTTPKerberosAuth(service='airflow')
class KerberosService: # pylint: disable=too-few-public-methods
"""Class to keep information about the Kerberos Service initialized"""
def __init__(self):
self.service_name = None
# Stores currently initialized Kerberos Service
_KERBEROS_SERVICE = KerberosService()
def init_app(app):
"""Initializes application with kerberos"""
hostname = app.config.get('SERVER_NAME')
if not hostname:
hostname = getfqdn()
log.info("Kerberos: hostname %s", hostname)
service = 'airflow'
_KERBEROS_SERVICE.service_name = f"{service}@{hostname}"
if 'KRB5_KTNAME' not in os.environ:
os.environ['KRB5_KTNAME'] = conf.get('kerberos', 'keytab')
try:
log.info("Kerberos init: %s %s", service, hostname)
principal = kerberos.getServerPrincipalDetails(service, hostname)
except kerberos.KrbError as err:
log.warning("Kerberos: %s", err)
else:
log.info("Kerberos API: server is %s", principal)
def _unauthorized():
"""
Indicate that authorization is required
:return:
"""
return Response("Unauthorized", 401, {"WWW-Authenticate": "Negotiate"})
def _forbidden():
return Response("Forbidden", 403)
def _gssapi_authenticate(token):
state = None
ctx = stack.top
try:
return_code, state = kerberos.authGSSServerInit(_KERBEROS_SERVICE.service_name)
if return_code != kerberos.AUTH_GSS_COMPLETE:
return None
return_code = kerberos.authGSSServerStep(state, token)
if return_code == kerberos.AUTH_GSS_COMPLETE:
ctx.kerberos_token = kerberos.authGSSServerResponse(state)
ctx.kerberos_user = kerberos.authGSSServerUserName(state)
return return_code
if return_code == kerberos.AUTH_GSS_CONTINUE:
return kerberos.AUTH_GSS_CONTINUE
return None
except kerberos.GSSError:
return None
finally:
if state:
kerberos.authGSSServerClean(state)
T = TypeVar("T", bound=Callable) # pylint: disable=invalid-name
def requires_authentication(function: T):
"""Decorator for functions that require authentication with Kerberos"""
@wraps(function)
def decorated(*args, **kwargs):
header = request.headers.get("Authorization")
if header:
ctx = stack.top
token = ''.join(header.split()[1:])
return_code = _gssapi_authenticate(token)
if return_code == kerberos.AUTH_GSS_COMPLETE:
g.user = ctx.kerberos_user
response = function(*args, **kwargs)
response = make_response(response)
if ctx.kerberos_token is not None:
response.headers['WWW-Authenticate'] = ' '.join(['negotiate', ctx.kerberos_token])
return response
if return_code != kerberos.AUTH_GSS_CONTINUE:
return _forbidden()
return _unauthorized()
return cast(T, decorated)
# 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.
"""Keycloak authentication backend"""
from functools import wraps
from typing import Callable, Optional, Tuple, TypeVar, Union, cast
from flask import Response, current_app, request
from flask_appbuilder.security.sqla.models import User
from flask_login import login_user
from requests.auth import AuthBase
import requests
import json
CLIENT_AUTH: Optional[Union[Tuple[str, str], AuthBase]] = None
def init_app(_):
"""Initializes authentication backend"""
T = TypeVar("T", bound=Callable) # pylint: disable=invalid-name
def auth_current_user() -> Optional[User]:
print("\n## 1. keycloak_auth.auth_current_user() ")
"""Authenticate and set current user if Authorization header exists"""
auth = request.authorization
print(f"\n## 2. keycloak_auth.auth_current_user() => request.authorization: {auth} ")
#if auth is None or not auth.username or not auth.password:
if auth is None or ((not 'access_token' in auth or not 'token' in auth) and (not 'username' in auth and not 'password' in auth)):
print(f"\n## 2.5 FAILED AUTH - NO MANDATORY PARAMETERS: (username + password | token | access_token ) \n\n")
return None
ab_security_manager = current_app.appbuilder.sm
user = None
if user is None:
oauth_providers = ab_security_manager.getOauthParams()
if not 'access_token' in auth and not 'token' in auth:
url = oauth_providers["access_token_url"]
reqToken = {'client_id': oauth_providers["client_id"],
'client_secret': oauth_providers["client_secret"],
'username': auth.username,
'password': auth.password,
'grant_type': 'password'}
resToken = requests.post(url, data = reqToken)
resTokenJSON = json.loads(resToken.text)
access_token = resTokenJSON['access_token']
print(f"\n## 3.A access_token NOT in auth (called oauth) => {access_token}")
else:
print(f"\n## 3.B access_token/token in auth => {auth}")
access_token = auth.access_token if 'access_token' in auth else auth.token
url = oauth_providers["api_base_url"] + "userinfo"
reqUserinfo = {'access_token': access_token}
resUserinfo = requests.post(url, data = reqUserinfo)
resUserinfoJSON = json.loads(resUserinfo.text)
print(f"\n\n## 4. USERINFO is => {resUserinfoJSON}")
resUserinfoJSON['username'] = resUserinfoJSON['preferred_username']
user = ab_security_manager.auth_user_oauth(resUserinfoJSON)
print(f"\n## 5. keycloak_auth.auth_user_oauth() user is => {user} \n")
if user is not None:
login_user(user, remember=False)
return user
def requires_authentication(function: T):
"""Decorator for functions that require authentication"""
@wraps(function)
def decorated(*args, **kwargs):
if auth_current_user() is not None:
return function(*args, **kwargs)
else:
return Response("Unauthorized", 401, {"WWW-Authenticate": "Basic"})
return cast(T, decorated)
......@@ -48,6 +48,8 @@ services:
- ./airflow.cfg:/opt/airflow/airflow.cfg
- ./webserver_config.py:/opt/airflow/webserver_config.py
- ./manager.py:/home/airflow/.local/lib/python3.8/site-packages/flask_appbuilder/security/manager.py
- ./themes:/home/airflow/.local/lib/python3.8/site-packages/flask_appbuilder/static/appbuilder/css/themes
- ./switch.07b9373717bbc645aa21.css:/home/airflow/.local/lib/python3.8/site-packages/airflow/www/static/dist/switch.07b9373717bbc645aa21.css
- ./navbar.html:/home/airflow/.local/lib/python3.8/site-packages/airflow/www/templates/appbuilder/navbar.html
- ./navbar_right.html:/home/airflow/.local/lib/python3.8/site-packages/airflow/www/templates/appbuilder/navbar_right.html
ports:
......
......@@ -272,6 +272,34 @@ class BaseSecurityManager(AbstractSecurityManager):
# Setup Flask-Jwt-Extended
self.jwt_manager = self.create_jwt_manager(app)
def getOauthParams(self):
"""
This method return Oauth Parameters for AUTH_OAUTH (remote) MMA
"""
if self.auth_type == AUTH_OAUTH:
for _provider in self.oauth_providers:
remote_app = _provider["remote_app"]
log.debug("OAuth parameters {0}".format(remote_app))
return remote_app
def getLoginURL(self, redirectURI):
"""
This method return the logout URI (BugFix logout from Airflow/Keycloak) MMA
:param redirectURI: redirect URI (login | logout)
"""
#config = self.appbuilder.get_app.config
#print(f"\n\n ### config => {config} \n\n")
remoteApp = self.appbuilder.get_app.config['OAUTH_PROVIDERS'][0]["remote_app"]
api_base_url = remoteApp["api_base_url"]
airflow_base_url = remoteApp["airflow_base_url"]
# 'http://keycloak:8080/auth/realms/airflow/protocol/openid-connect/logout?redirect_uri=http://airflow:8280/logout'
redirectURI = api_base_url + "" + "logout" + "?redirect_uri=" + airflow_base_url + "" + redirectURI
#print(f"\n\n\n ### redirectURI ==> {redirectURI} \n\n")
return redirectURI
def create_login_manager(self, app) -> LoginManager:
"""
Override to implement your custom login manager instance
......
......@@ -78,27 +78,19 @@
<ul class="dropdown-menu">
<li><a href="{{appbuilder.get_url_for_userinfo}}"><span class="material-icons">account_circle</span>{{_("Your Profile")}}</a></li>
<li role="separator" class="divider"></li>
<!-- [FIX ABOUT LOGOUT] -->
<!-- <li><a href="{{appbuilder.get_url_for_logout}}"><span class="material-icons">exit_to_app</span>{{_("Log Out")}}</a></li> -->
<script language="JavaScript">
function force_logout_keycloak(url) { window.location.href = url; }
</script>
<li>
<a href="#" onclick="force_logout_keycloak('http://keycloak:8080/auth/realms/airflow/protocol/openid-connect/logout?redirect_uri=http://airflow:8280/logout')">
<span class="material-icons">exit_to_app</span>{{_("Log Out")}}
</a>
</li>
<!-- [FIX ABOUT LOGOUT] -->
<!-- <a href="{{appbuilder.get_url_for_logout}}"><span class="material-icons">exit_to_app</span>{{_("Log Out")}}</a></li> -->
<a href="{{appbuilder.sm.getLoginURL('logout')}}")'><span class="material-icons">exit_to_app</span>{{_("Log Out")}}</a>
<!-- [/FIX ABOUT LOGOUT] -->
</li>
</ul>
</li>
{% else %}
<li>
<!-- [FIX ABOUT LOGIN] -->
<!-- [FIX ABOUT LOGOUT] -->
<!-- <a href="{{appbuilder.get_url_for_login}}"><span class="material-icons">login</span>{{_("Log In")}}</a> -->
<script language="JavaScript">
function force_loging_keycloak(url) { window.location.href = url; }
</script>
<a href="#" onclick="force_loging_keycloak('http://keycloak:8080/auth/realms/airflow/protocol/openid-connect/logout?redirect_uri=http://airflow:8280/login')"><span class="material-icons">login</span>{{_("Log In")}}</a>
<!-- [/FIX ABOUT LOGIN] -->
<a href="{{appbuilder.sm.getLoginURL('login')}}"><span class="material-icons">login</span>{{_("Log In")}}</a>
<!-- [/FIX ABOUT LOGOUT] -->
</li>
{% endif %}
/*!
* 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.
*/.switch-label{display:inline-block;margin:0;cursor:pointer;font-weight:400}.switch-label.disabled{cursor:not-allowed}.switch-input{position:absolute;overflow:hidden;clip:rect(1px,1px,1px,1px);border:0;width:1px;height:1px;padding:0;white-space:nowrap;clip-path:inset(50%)}.switch{box-sizing:content-box;display:inline-flex;align-items:center;vertical-align:middle;border-radius:999px;width:2.5rem;padding:2px;background-color:#c4c2c1;cursor:pointer}.switch:before{border-radius:50%;width:1.5rem;height:1.5rem;content:"";background-color:#edecec;transition-timing-function:ease-in-out;transition-duration:.25s;transition-property:transform,background-color}.switch-input:disabled+.switch{opacity:.4;cursor:not-allowed}.switch-input:checked+.switch{background-color:#139973}.switch-input:checked+.switch:before{background-color:#fffefd;transform:translateX(1rem)}.switch-input:focus+.switch{box-shadow:0 0 0 3px rgba(1,124,238,.4)}.switch-input:not(:checked)+.switch:hover{background-color:#9e9e9c}.switch-input:checked.switch-input--error+.switch{background-color:#e43921}.switch-input:not(:checked).switch-input--error+.switch{background-color:#824840}.switch-input:focus+.switch:before{background-color:#fff}.switch-input:not(:checked)+.switch:hover:before{background-color:#f5f5f5}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment