Skip to content

Commit

Permalink
Merge branch 'master' into fix/oauth-login-register-500-error
Browse files Browse the repository at this point in the history
  • Loading branch information
dpgaspar authored Oct 11, 2023
2 parents e40b31a + dcf8684 commit 6fb9cfa
Show file tree
Hide file tree
Showing 9 changed files with 244 additions and 74 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Flask-AppBuilder ChangeLog
Improvements and Bug fixes on 4.3.7
-----------------------------------

- fix: fix: swagger missing nonce (#2116) [Daniel Vaz Gaspar]
- fix: swagger missing nonce (#2116) [Daniel Vaz Gaspar]

Improvements and Bug fixes on 4.3.6
-----------------------------------
Expand Down
13 changes: 8 additions & 5 deletions docs/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ Specify a list of OAUTH_PROVIDERS in **config.py** that you want to allow for yo
"client_kwargs": {
"scope": "User.read name preferred_username email profile upn",
"resource": "AZURE_APPLICATION_ID",
# Optionally enforce signature JWT verification
"verify_signature": False
},
"request_token_url": None,
"access_token_url": "https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/token",
Expand Down Expand Up @@ -347,10 +349,13 @@ You can give FlaskAppBuilder roles based on Oauth groups::
To customize the userinfo retrieval, you can create your own method like this::

@appbuilder.sm.oauth_user_info_getter
def my_user_info_getter(sm, provider, response=None):
def my_user_info_getter(
sm: SecurityManager,
provider: str,
response: Dict[str, Any]
) -> Dict[str, Any]:
if provider == "okta":
me = sm.oauth_remotes[provider].get("userinfo")
log.debug("User info from Okta: {0}".format(me.data))
return {
"username": "okta_" + me.data.get("sub", ""),
"first_name": me.data.get("given_name", ""),
Expand All @@ -365,11 +370,9 @@ To customize the userinfo retrieval, you can create your own method like this::
"email": me.json().get("email"),
"first_name": me.json().get("given_name", ""),
"last_name": me.json().get("family_name", ""),
"id": me.json().get("sub", ""),
"role_keys": ["User"], # set AUTH_ROLES_SYNC_AT_LOGIN = False
}
else:
return {}
return {}

On Flask-AppBuilder 3.4.0 the login page has changed.

Expand Down
6 changes: 3 additions & 3 deletions examples/oauth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,17 @@
"remote_app": {
"client_id": os.environ.get("AZURE_APPLICATION_ID"),
"client_secret": os.environ.get("AZURE_SECRET"),
"api_base_url": "https://login.microsoftonline.com/{AZURE_TENANT_ID}/oauth2",
"api_base_url": f"https://login.microsoftonline.com/{os.environ.get('AZURE_TENANT_ID')}/oauth2",
"client_kwargs": {
"scope": "User.read name preferred_username email profile upn",
"resource": os.environ.get("AZURE_APPLICATION_ID"),
},
"request_token_url": None,
"access_token_url": f"https://login.microsoftonline.com/"
f"{os.environ.get('AZURE_APPLICATION_ID')}/"
f"{os.environ.get('AZURE_TENANT_ID')}/"
"oauth2/token",
"authorize_url": f"https://login.microsoftonline.com/"
f"{os.environ.get('AZURE_APPLICATION_ID')}/"
f"{os.environ.get('AZURE_TENANT_ID')}/"
f"oauth2/authorize",
},
},
Expand Down
6 changes: 6 additions & 0 deletions flask_appbuilder/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,9 @@
API_ADD_TITLE_RIS_KEY = "add_title"
API_EDIT_TITLE_RIS_KEY = "edit_title"
API_SHOW_TITLE_RIS_KEY = "show_title"

# -----------------------------------
# OAuth Provider Constants
# -----------------------------------

MICROSOFT_KEY_SET_URL = "https://login.microsoftonline.com/common/discovery/keys"
8 changes: 8 additions & 0 deletions flask_appbuilder/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,11 @@ class ApplyFilterException(FABException):
"""When executing an apply filter a SQLAlchemy exception happens"""

...


class OAuthProviderUnknown(FABException):
"""
When an OAuth provider is not supported/unknown
"""

...
96 changes: 35 additions & 61 deletions flask_appbuilder/security/manager.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import base64
import datetime
import json
import logging
import re
from typing import Any, Dict, List, Optional, Set, Tuple, Union
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union

from flask import Flask, g, session, url_for
from flask_appbuilder.exceptions import OAuthProviderUnknown
from flask_babel import lazy_gettext as _
from flask_jwt_extended import current_user as current_user_jwt
from flask_jwt_extended import JWTManager
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_login import current_user, LoginManager
import jwt
from werkzeug.security import check_password_hash, generate_password_hash

from .api import SecurityApi
Expand Down Expand Up @@ -54,6 +54,7 @@
LOGMSG_WAR_SEC_LOGIN_FAILED,
LOGMSG_WAR_SEC_NO_USER,
LOGMSG_WAR_SEC_NOLDAP_OBJ,
MICROSOFT_KEY_SET_URL,
PERMISSION_PREFIX,
)

Expand Down Expand Up @@ -269,7 +270,7 @@ def __init__(self, appbuilder):
from authlib.integrations.flask_client import OAuth

self.oauth = OAuth(app)
self.oauth_remotes = dict()
self.oauth_remotes = {}
for _provider in self.oauth_providers:
provider_name = _provider["name"]
log.debug("OAuth providers init %s", provider_name)
Expand Down Expand Up @@ -517,7 +518,10 @@ def current_user(self):
elif current_user_jwt:
return current_user_jwt

def oauth_user_info_getter(self, f):
def oauth_user_info_getter(
self,
func: Callable[["BaseSecurityManager", str, Dict[str, Any]], Dict[str, Any]],
):
"""
Decorator function to be the OAuth user info getter
for all the providers, receives provider and response
Expand All @@ -532,21 +536,11 @@ def my_oauth_user_info(sm, provider, response=None):
if provider == 'github':
me = sm.oauth_remotes[provider].get('user')
return {'username': me.data.get('login')}
else:
return {}
return {}
"""

def wraps(provider, response=None):
ret = f(self, provider, response=response)
# Checks if decorator is well behaved and returns a dict as supposed.
if not type(ret) == dict:
log.error(
"OAuth user info decorated function "
"did not returned a dict, but: %s",
type(ret),
)
return {}
return ret
def wraps(provider: str, response: Dict[str, Any] = None) -> Dict[str, Any]:
return func(self, provider, response)

self.oauth_user_info = wraps
return wraps
Expand Down Expand Up @@ -585,9 +579,11 @@ def set_oauth_session(self, provider, oauth_response):
)
session["oauth_provider"] = provider

def get_oauth_user_info(self, provider, resp):
def get_oauth_user_info(
self, provider: str, resp: Dict[str, Any]
) -> Dict[str, Any]:
"""
Since there are different OAuth API's with different ways to
Since there are different OAuth APIs with different ways to
retrieve user info
"""
# for GITHUB
Expand Down Expand Up @@ -626,23 +622,14 @@ def get_oauth_user_info(self, provider, resp):
"last_name": data.get("family_name", ""),
"email": data.get("email", ""),
}
# for Azure AD Tenant. Azure OAuth response contains
# JWT token which has user info.
# JWT token needs to be base64 decoded.
# https://docs.microsoft.com/en-us/azure/active-directory/develop/
# active-directory-protocols-oauth-code
if provider == "azure":
log.debug("Azure response received : %s", resp)
id_token = resp["id_token"]
log.debug(str(id_token))
me = self._azure_jwt_token_parse(id_token)
log.debug("Parse JWT token : %s", me)
me = self._decode_and_validate_azure_jwt(resp["id_token"])
log.debug("User info from Azure: %s", me)
# https://learn.microsoft.com/en-us/azure/active-directory/develop/id-token-claims-reference#payload-claims
return {
"name": me.get("name", ""),
"email": me["upn"],
"email": me["email"],
"first_name": me.get("given_name", ""),
"last_name": me.get("family_name", ""),
"id": me["oid"],
"username": me["oid"],
"role_keys": me.get("roles", []),
}
Expand Down Expand Up @@ -680,39 +667,26 @@ def get_oauth_user_info(self, provider, resp):
"last_name": data.get("family_name", ""),
"email": data.get("email", ""),
}
else:
return {}

def _azure_parse_jwt(self, id_token):
jwt_token_parts = r"^([^\.\s]*)\.([^\.\s]+)\.([^\.\s]*)$"
matches = re.search(jwt_token_parts, id_token)
if not matches or len(matches.groups()) < 3:
log.error("Unable to parse token.")
return {}
return {
"header": matches.group(1),
"Payload": matches.group(2),
"Sig": matches.group(3),
}
raise OAuthProviderUnknown()

def _azure_jwt_token_parse(self, id_token):
jwt_split_token = self._azure_parse_jwt(id_token)
if not jwt_split_token:
return
def _get_microsoft_jwks(self) -> List[Dict[str, Any]]:
import requests

jwt_payload = jwt_split_token["Payload"]
# Prepare for base64 decoding
payload_b64_string = jwt_payload
payload_b64_string += "=" * (4 - ((len(jwt_payload) % 4)))
decoded_payload = base64.urlsafe_b64decode(payload_b64_string.encode("ascii"))
return requests.get(MICROSOFT_KEY_SET_URL).json()

if not decoded_payload:
log.error("Payload of id_token could not be base64 url decoded.")
return
def _decode_and_validate_azure_jwt(self, id_token: str) -> Dict[str, str]:
verify_signature = self.oauth_remotes["azure"].client_kwargs.get(
"verify_signature", False
)
if verify_signature:
from authlib.jose import JsonWebKey, jwt as authlib_jwt

jwt_decoded_payload = json.loads(decoded_payload.decode("utf-8"))
keyset = JsonWebKey.import_key_set(self._get_microsoft_jwks())
claims = authlib_jwt.decode(id_token, keyset)
claims.validate()
return claims

return jwt_decoded_payload
return jwt.decode(id_token, options={"verify_signature": False})

def register_views(self):
if not self.appbuilder.app.config.get("FAB_ADD_SECURITY_VIEWS", True):
Expand Down
2 changes: 1 addition & 1 deletion requirements-extra.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ mysqlclient==2.0.1
psycopg2-binary==2.9.6
pyodbc==4.0.35
requests==2.26.0
Authlib==0.15.4
Authlib==1.2.1
python-ldap==3.3.1
flask-openid==1.3.0
Loading

0 comments on commit 6fb9cfa

Please sign in to comment.