diff --git a/docs/config.rst b/docs/config.rst index bad0a30fa4..a6fd5a389e 100755 --- a/docs/config.rst +++ b/docs/config.rst @@ -34,6 +34,14 @@ Use config.py to configure the following parameters. By default it will use SQLL | | exist. Mandatory when using user | | | | registration | | +----------------------------------------+--------------------------------------------+-----------+ +| AUTH_ROLES_SYNC_AT_LOGIN | Sets if user's roles are replaced each | No | +| | login with those received from LDAP/OAUTH | | +| | Default: False | | ++----------------------------------------+--------------------------------------------+-----------+ +| AUTH_ROLES_MAPPING | A mapping from LDAP/OAUTH group names | No | +| | to FAB roles | | +| | | | ++----------------------------------------+--------------------------------------------+-----------+ | AUTH_LDAP_SERVER | define your ldap server when AUTH_TYPE=2 | Cond. | | | example: | | | | | | @@ -96,6 +104,27 @@ Use config.py to configure the following parameters. By default it will use SQLL | | | | | | AUTH_LDAP_UID_FIELD = "uid" | | +----------------------------------------+--------------------------------------------+-----------+ +| AUTH_LDAP_GROUP_FIELD | sets the field in the ldap directory that | No | +| | stores the user's group uids. This field | | +| | is used in combination with | | +| | AUTH_ROLES_MAPPING to propagate the users | | +| | groups into the User database. | | +| | Default is "memberOf". | | +| | example: | | +| | | | +| | AUTH_TYPE = 2 | | +| | | | +| | AUTH_LDAP_SERVER = "ldap://ldapserver.new" | | +| | | | +| | AUTH_LDAP_SEARCH = "ou=people,dc=example" | | +| | | | +| | AUTH_LDAP_GROUP_FIELD = "memberOf" | | +| | | | +| | AUTH_ROLES_MAPPING = { | | +| | "cn=User,ou=groups,dc=example,dc=com": | | +| | "User" | | +| | } | | ++----------------------------------------+--------------------------------------------+-----------+ | AUTH_LDAP_FIRSTNAME_FIELD | sets the field in the ldap directory that | No | | | stores the user's first name. This field | | | | is used to propagate user's first name | | diff --git a/docs/security.rst b/docs/security.rst index 59e3a458c3..d1c38e5b58 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -466,16 +466,22 @@ key is just the configuration for flask-oauthlib:: AUTH_TYPE = AUTH_OAUTH OAUTH_PROVIDERS = [ - {'name':'twitter', 'icon':'fa-twitter', - 'remote_app': { - 'consumer_key':'TWITTER KEY', - 'consumer_secret':'TWITTER SECRET', - 'base_url':'https://api.twitter.com/1.1/', - 'request_token_url':'https://api.twitter.com/oauth/request_token', - 'access_token_url':'https://api.twitter.com/oauth/access_token', - 'authorize_url':'https://api.twitter.com/oauth/authenticate'} + { + 'name':'twitter', + 'icon':'fa-twitter', + 'remote_app': { + 'consumer_key':'TWITTER KEY', + 'consumer_secret':'TWITTER SECRET', + 'base_url':'https://api.twitter.com/1.1/', + 'request_token_url':'https://api.twitter.com/oauth/request_token', + 'access_token_url':'https://api.twitter.com/oauth/access_token', + 'authorize_url':'https://api.twitter.com/oauth/authenticate' + } }, - {'name':'google', 'icon':'fa-google', 'token_key':'access_token', + { + 'name':'google', + 'icon':'fa-google', + 'token_key':'access_token', 'remote_app': { 'consumer_key':'GOOGLE KEY', 'consumer_secret':'GOOGLE SECRET', @@ -486,14 +492,29 @@ key is just the configuration for flask-oauthlib:: 'request_token_url':None, 'access_token_url':'https://accounts.google.com/o/oauth2/token', 'authorize_url':'https://accounts.google.com/o/oauth2/auth'} - } + }, + { + 'name': 'okta', + 'icon': 'fa-circle-o', + 'token_key': 'access_token', + 'remote_app': { + 'consumer_key': 'OKTA_KEY', + 'consumer_secret': 'OKTA_SECRET', + 'base_url': 'https://OKTA_DOMAIN.okta.com/oauth2/v1/', + 'request_token_params': { + 'scope': 'openid profile email groups' + }, + 'access_token_url': 'https://OKTA_DOMAIN.okta.com/oauth2/v1/token', + 'authorize_url': 'https://OKTA_DOMAIN.okta.com/oauth2/v1/authorize', + }, + }, ] This needs a small explanation, you basically have five special keys: :name: The name of the provider, you can choose whatever you want. But the framework as some builtin logic to retrieve information about a user that you can make use of if you choose: - 'twitter', 'google', 'github','linkedin'. + 'twitter', 'google', 'github', 'linkedin', 'okta'. :icon: The font-awesome icon for this provider. :token_key: The token key name that this provider uses, google and github uses *'access_token'*, @@ -523,6 +544,78 @@ this provider with, **response** is the response. Take a look at the `example `_ +External Role Mapping +-------------------- + +:note: currently we only support mapping external groups into FAB roles with: AUTH_LDAP, AUTH_OAUTH (Okta) + +If you have an external source of truth for groups, you might want to have FAB sync user's roles from that system +as they login. + +Here is an example config for LDAP, (Note this is for Okta LDAP, but can be extended to any LDAP provider):: + + # Force users to re-auth after 15min of inactivity + # NOTE: this is important to keep roles in sync + PERMANENT_SESSION_LIFETIME = 900 + + AUTH_USER_REGISTRATION = True + AUTH_USER_REGISTRATION_ROLE = "Viewer" + + AUTH_ROLES_SYNC_AT_LOGIN = True + AUTH_ROLES_MAPPING = { + "cn=User,ou=groups,dc=OKTA_DOMAIN,dc=com": "User", + "cn=Admin,ou=groups,dc=OKTA_DOMAIN,dc=com": "Admin", + } + + AUTH_TYPE = AUTH_LDAP + AUTH_LDAP_SERVER = "ldaps://OKTA_DOMAIN.ldap.okta.com:636" + AUTH_LDAP_USE_TLS = False + + AUTH_LDAP_BIND_USER = "uid=bind-admin,dc=OKTA_DOMAIN,dc=okta,dc=com" + AUTH_LDAP_BIND_PASSWORD = "xxxxxxxxxxxx" + + AUTH_LDAP_SEARCH = "ou=users,dc=OKTA_DOMAIN,dc=okta,dc=com" + AUTH_LDAP_SEARCH_FILTER = "(objectclass=inetOrgPerson)" + AUTH_LDAP_APPEND_DOMAIN = "OKTA_DOMAIN.com" + + AUTH_LDAP_UID_FIELD = "uid" + AUTH_LDAP_GROUP_FIELD = "memberOf" + AUTH_LDAP_FIRSTNAME_FIELD = "givenName" + AUTH_LDAP_LASTNAME_FIELD = "sn" + AUTH_LDAP_EMAIL_FIELD = "email" + +Here is an example config for OAUTH, (Note this is for Okta OAUTH):: + + # Force users to re-auth after 15min of inactivity + # NOTE: this is important to keep roles in sync + PERMANENT_SESSION_LIFETIME = 900 + + AUTH_USER_REGISTRATION = True + AUTH_USER_REGISTRATION_ROLE = "Viewer" + + AUTH_ROLES_SYNC_AT_LOGIN = True + AUTH_ROLES_MAPPING = { + "USER_GROUP_NAME": "User", + "ADMIN_GROUP_NAME": "Admin", + } + + OAUTH_PROVIDERS = [ + { + "name": "okta", + "icon": "fa-circle-o", + "token_key": "access_token", + "remote_app": { + "consumer_key": "OKTA_KEY", + "consumer_secret": "OKTA_SECRET", + "base_url": "https://OKTA_DOMAIN.okta.com/oauth2/v1/", + "request_token_params": { + "scope": "openid profile email groups" + }, + "access_token_url": "https://OKTA_DOMAIN.okta.com/oauth2/v1/token", + "authorize_url": "https://OKTA_DOMAIN.okta.com/oauth2/v1/authorize", + } + ] + Your Custom Security -------------------- diff --git a/examples/oauth/config.py b/examples/oauth/config.py index 1ddad40393..466046f44f 100644 --- a/examples/oauth/config.py +++ b/examples/oauth/config.py @@ -83,6 +83,25 @@ "authorize_url": "https://login.microsoftonline.com/{AZURE_TENANT_ID}/oauth2/authorize", }, }, + { + "name": "okta", + "icon": "fa-circle-o", + "token_key": "access_token", + "remote_app": { + "consumer_key": os.environ.get("OKTA_KEY"), + "consumer_secret": os.environ.get("OKTA_SECRET"), + "base_url": "https://{}.okta.com/oauth2/v1/".format( + os.environ.get("OKTA_DOMAIN") + ), + "request_token_params": {"scope": "openid profile email groups"}, + "access_token_url": "https://{}.okta.com/oauth2/v1/token".format( + os.environ.get("OKTA_DOMAIN") + ), + "authorize_url": "https://{}.okta.com/oauth2/v1/authorize".format( + os.environ.get("OKTA_DOMAIN") + ), + }, + }, ] # Uncomment to setup Full admin role name @@ -97,6 +116,19 @@ # The default user self registration role AUTH_USER_REGISTRATION_ROLE = "Admin" +# Replace users database roles each login with those received from OAUTH/LDAP +AUTH_ROLES_SYNC_AT_LOGIN = True + +# A mapping from LDAP/OAUTH group names to FAB roles +AUTH_ROLES_MAPPING = { + # For OAUTH + "USER_GROUP_NAME": "User", + "ADMIN_GROUP_NAME": "Admin", + # For LDAP + # "cn=User,ou=groups,dc=example,dc=com": "User", + # "cn=Admin,ou=groups,dc=example,dc=com": "Admin", +} + # When using LDAP Auth, setup the ldap server # AUTH_LDAP_SERVER = "ldap://ldapserver.new" # AUTH_LDAP_USE_TLS = False diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index 078a4bf870..9381bf4412 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -51,7 +51,6 @@ LOGMSG_ERR_SEC_AUTH_LDAP_TLS, LOGMSG_WAR_SEC_LOGIN_FAILED, LOGMSG_WAR_SEC_NO_USER, - LOGMSG_WAR_SEC_NOLDAP_OBJ, PERMISSION_PREFIX, ) @@ -215,6 +214,9 @@ def __init__(self, appbuilder): # Self Registration app.config.setdefault("AUTH_USER_REGISTRATION", False) app.config.setdefault("AUTH_USER_REGISTRATION_ROLE", self.auth_role_public) + # Role Mapping + app.config.setdefault("AUTH_ROLES_MAPPING", {}) + app.config.setdefault("AUTH_ROLES_SYNC_AT_LOGIN", False) # LDAP Config if self.auth_type == AUTH_LDAP: @@ -239,6 +241,7 @@ def __init__(self, appbuilder): app.config.setdefault("AUTH_LDAP_TLS_KEYFILE", "") # Mapping options app.config.setdefault("AUTH_LDAP_UID_FIELD", "uid") + app.config.setdefault("AUTH_LDAP_GROUP_FIELD", "memberOf") app.config.setdefault("AUTH_LDAP_FIRSTNAME_FIELD", "givenName") app.config.setdefault("AUTH_LDAP_LASTNAME_FIELD", "sn") app.config.setdefault("AUTH_LDAP_EMAIL_FIELD", "mail") @@ -296,6 +299,29 @@ def create_jwt_manager(self, app) -> JWTManager: def create_builtin_roles(self): return self.appbuilder.get_app.config.get("FAB_ROLES", {}) + def get_roles_from_keys(self, user_role_keys): + """ + Construct a list of FAB role objects, using AUTH_ROLES_MAPPING + to map from a provided list of keys to the true FAB role names. + + :param user_role_keys: the list of keys + :return: a list of RoleModelView + """ + _roles = [] + _user_role_keys = set(user_role_keys) + for role_key, role_name in self.auth_roles_mapping.items(): + if role_key in _user_role_keys: + fab_role = self.find_role(role_name) + if fab_role: + _roles.append(fab_role) + else: + log.warning( + "Can't find role specified in AUTH_ROLES_MAPPING: {0}".format( + role_name + ) + ) + return _roles + @property def get_url_for_registeruser(self): return url_for( @@ -347,6 +373,14 @@ def auth_user_registration(self): def auth_user_registration_role(self): return self.appbuilder.get_app.config["AUTH_USER_REGISTRATION_ROLE"] + @property + def auth_roles_mapping(self): + return self.appbuilder.get_app.config["AUTH_ROLES_MAPPING"] + + @property + def auth_roles_sync_at_login(self): + return self.appbuilder.get_app.config["AUTH_ROLES_SYNC_AT_LOGIN"] + @property def auth_ldap_search(self): return self.appbuilder.get_app.config["AUTH_LDAP_SEARCH"] @@ -375,6 +409,9 @@ def auth_ldap_username_format(self): def auth_ldap_uid_field(self): return self.appbuilder.get_app.config["AUTH_LDAP_UID_FIELD"] + def auth_ldap_group_field(self): + return self.appbuilder.get_app.config["AUTH_LDAP_GROUP_FIELD"] + @property def auth_ldap_firstname_field(self): return self.appbuilder.get_app.config["AUTH_LDAP_FIRSTNAME_FIELD"] @@ -508,12 +545,12 @@ def get_oauth_user_info(self, provider, resp): log.debug("User info from Github: {0}".format(me.data)) return {"username": "github_" + me.data.get("login")} # for twitter - if provider == "twitter": + elif provider == "twitter": me = self.appbuilder.sm.oauth_remotes[provider].get("account/settings.json") log.debug("User info from Twitter: {0}".format(me.data)) return {"username": "twitter_" + me.data.get("screen_name", "")} # for linkedin - if provider == "linkedin": + elif provider == "linkedin": me = self.appbuilder.sm.oauth_remotes[provider].get( "people/~:(id,email-address,first-name,last-name)?format=json" ) @@ -525,7 +562,7 @@ def get_oauth_user_info(self, provider, resp): "last_name": me.data.get("lastName", ""), } # for Google - if provider == "google": + elif provider == "google": me = self.appbuilder.sm.oauth_remotes[provider].get("userinfo") log.debug("User info from Google: {0}".format(me.data)) return { @@ -539,7 +576,7 @@ def get_oauth_user_info(self, provider, resp): # 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": + elif provider == "azure": log.debug("Azure response received : {0}".format(resp)) id_token = resp["id_token"] log.debug(str(id_token)) @@ -553,6 +590,17 @@ def get_oauth_user_info(self, provider, resp): "id": me["oid"], "username": me["oid"], } + # for okta + elif provider == "okta": + me = self.appbuilder.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", ""), + "last_name": me.data.get("family_name", ""), + "email": me.data.get("email", ""), + "role_keys": me.data.get("groups", []), + } else: return {} @@ -771,15 +819,13 @@ def auth_user_db(self, username, password): def _search_ldap(self, ldap, con, username): """ - Searches LDAP for user, assumes ldap_search is set. + Searches LDAP for user. :param ldap: The ldap module reference :param con: The ldap connection :param username: username to match with auth_ldap_uid_field :return: ldap object array """ - if self.auth_ldap_append_domain: - username = username + "@" + self.auth_ldap_append_domain if self.auth_ldap_search_filter: filter_str = "(&%s(%s=%s))" % ( self.auth_ldap_search_filter, @@ -788,16 +834,20 @@ def _search_ldap(self, ldap, con, username): ) else: filter_str = "(%s=%s)" % (self.auth_ldap_uid_field, username) + + request_fields = [ + self.auth_ldap_firstname_field, + self.auth_ldap_lastname_field, + self.auth_ldap_email_field, + ] + if len(self.auth_roles_mapping) > 0: + request_fields.append(self.auth_ldap_group_field) + + self._bind_indirect_user(ldap, con) user = con.search_s( - self.auth_ldap_search, - ldap.SCOPE_SUBTREE, - filter_str, - [ - self.auth_ldap_firstname_field, - self.auth_ldap_lastname_field, - self.auth_ldap_email_field, - ], + self.auth_ldap_search, ldap.SCOPE_SUBTREE, filter_str, request_fields ) + if user: if not user[0][0]: return None @@ -816,35 +866,17 @@ def _bind_indirect_user(self, ldap, con): con.bind_s(indirect_user, indirect_password) log.debug("LDAP BIND indirect OK") - def _bind_ldap(self, ldap, con, username, password): + def _validate_login_ldap(self, ldap, con, user_dn, password): """ - Private to bind/Authenticate a user. - If AUTH_LDAP_BIND_USER exists then it will bind first with it, - next will search the LDAP server using the username with UID - and try to bind to it (OpenLDAP). - If AUTH_LDAP_BIND_USER does not exit, will bind with username/password + Validates the provided user_dn/password against the LDAP sever. """ + self._bind_indirect_user(ldap, con) try: - if self.auth_ldap_bind_user: - self._bind_indirect_user(ldap, con) - user = self._search_ldap(ldap, con, username) - if user: - log.debug("LDAP got User {0}".format(user)) - # username = DN from search - username = user[0][0] - else: - log.debug("LDAP bind failure: user not found") - return False - log.debug("LDAP bind with: {0} {1}".format(username, "XXXXXX")) - if self.auth_ldap_username_format: - username = self.auth_ldap_username_format % username - if self.auth_ldap_append_domain: - username = username + "@" + self.auth_ldap_append_domain - con.bind_s(username, password) - log.debug("LDAP bind OK: {0}".format(username)) + log.debug("LDAP bind TRY: user_dn={0} pass={1}".format(user_dn, "XXXXXX")) + con.bind_s(user_dn, password) + log.debug("LDAP bind SUCCESS: {0}".format(user_dn)) return True except ldap.INVALID_CREDENTIALS: - log.debug("LDAP bind failure: invalid credentials") return False @staticmethod @@ -853,6 +885,11 @@ def ldap_extract(ldap_dict, field, fallback): return fallback return ldap_dict[field][0].decode("utf-8") or fallback + @staticmethod + def ldap_extract_list(ldap_dict, field): + raw_list = ldap_dict.get(field, []) + return [x.decode("utf-8") for x in raw_list if x.decode("utf-8")] + def auth_user_ldap(self, username, password): """ Method for authenticating user, auth LDAP style. @@ -866,6 +903,11 @@ def auth_user_ldap(self, username, password): """ if username is None or username == "": return None + else: + if self.auth_ldap_append_domain: + username = username + "@" + self.auth_ldap_append_domain + if self.auth_ldap_username_format: + username = self.auth_ldap_username_format % username user = self.find_user(username=username) if user is not None and (not user.is_active): return None @@ -904,39 +946,76 @@ def auth_user_ldap(self, username, password): LOGMSG_ERR_SEC_AUTH_LDAP_TLS.format(self.auth_ldap_server) ) return None - # Authenticate user - if not self._bind_ldap(ldap, con, username, password): + + # Lookup the user in LDAP + ldap_result = self._search_ldap(ldap, con, username) + if ldap_result: + # extract the user's DN + user_dn = ldap_result[0][0] + log.debug("LDAP got user's DN: {0}".format(user_dn)) + + # extract the user's other other info + user_info = ldap_result[0][1] + log.debug("LDAP got user's info: {0}".format(user_info)) + else: + log.debug("LDAP lookup failure for username: {0}".format(username)) + log.info(LOGMSG_WAR_SEC_LOGIN_FAILED.format(username)) + return None + + # Validate user's password by binding to LDAP + if not self._validate_login_ldap(ldap, con, user_dn, password): if user: self.update_user_auth_stat(user, False) - log.warning(LOGMSG_WAR_SEC_LOGIN_FAILED.format(username)) + log.info(LOGMSG_WAR_SEC_LOGIN_FAILED.format(username)) return None + + # Calculate the user's roles + user_role_objects = [] + if len(self.auth_roles_mapping) > 0: + user_role_keys = self.ldap_extract_list( + user_info, self.auth_ldap_group_field + ) + user_role_objects += self.get_roles_from_keys(user_role_keys) + if self.auth_user_registration: + user_role_objects += [ + self.find_role(self.auth_user_registration_role) + ] + log.debug( + "Calculated roles for user: {0} as: {1}".format( + username, user_role_objects + ) + ) + + # If the user is in the DB, update their roles + if user and self.auth_roles_sync_at_login: + user.roles = user_role_objects + # If user does not exist on the DB and not self user registration, go away if not user and not self.auth_user_registration: return None - # User does not exist, create one if self registration. - elif not user and self.auth_user_registration: - self._bind_indirect_user(ldap, con) - new_user = self._search_ldap(ldap, con, username) - if not new_user: - log.warning(LOGMSG_WAR_SEC_NOLDAP_OBJ.format(username)) - return None - ldap_user_info = new_user[0][1] - if self.auth_user_registration and user is None: - user = self.add_user( - username=username, - first_name=self.ldap_extract( - ldap_user_info, self.auth_ldap_firstname_field, username - ), - last_name=self.ldap_extract( - ldap_user_info, self.auth_ldap_lastname_field, username - ), - email=self.ldap_extract( - ldap_user_info, - self.auth_ldap_email_field, - username + "@email.notfound", - ), - role=self.find_role(self.auth_user_registration_role), + + # User does not exist, create one if self registration + if not user and self.auth_user_registration: + user = self.add_user( + username=username, + first_name=self.ldap_extract( + user_info, self.auth_ldap_firstname_field, username + ), + last_name=self.ldap_extract( + user_info, self.auth_ldap_lastname_field, username + ), + email=self.ldap_extract( + user_info, + self.auth_ldap_email_field, + username + "@email.notfound", + ), + role=user_role_objects, + ) + if not user: + log.error( + "Error creating a new LDAP user: {0}".format(username) ) + return None self.update_user_auth_stat(user) return user @@ -1015,6 +1094,24 @@ def auth_user_oauth(self, userinfo): if user and not user.is_active: log.info(LOGMSG_WAR_SEC_LOGIN_FAILED.format(userinfo)) return None + + # Calculate the user's roles + user_role_objects = [] + if len(self.auth_roles_mapping) > 0: + user_role_keys = userinfo.get("role_keys", []) + user_role_objects += self.get_roles_from_keys(user_role_keys) + if self.auth_user_registration: + user_role_objects += [self.find_role(self.auth_user_registration_role)] + log.debug( + "Calculated roles for user: {0} as: {1}".format( + userinfo["username"], user_role_objects + ) + ) + + # If the user is in the DB, update their roles + if user and self.auth_roles_sync_at_login: + user.roles = user_role_objects + # If user does not exist on the DB and not self user registration, go away if not user and not self.auth_user_registration: return None @@ -1025,10 +1122,12 @@ def auth_user_oauth(self, userinfo): first_name=userinfo.get("first_name", ""), last_name=userinfo.get("last_name", ""), email=userinfo.get("email", ""), - role=self.find_role(self.auth_user_registration_role), + role=user_role_objects, ) if not user: - log.error("Error creating a new OAuth user %s" % userinfo["username"]) + log.error( + "Error creating a new OAuth user: {0}".format(userinfo["username"]) + ) return None self.update_user_auth_stat(user) return user diff --git a/flask_appbuilder/security/sqla/manager.py b/flask_appbuilder/security/sqla/manager.py index 93bc01b977..fee53a98ee 100644 --- a/flask_appbuilder/security/sqla/manager.py +++ b/flask_appbuilder/security/sqla/manager.py @@ -202,7 +202,7 @@ def add_user( user.username = username user.email = email user.active = True - user.roles.append(role) + user.roles = role if isinstance(role, list) else [role] if hashed_password: user.password = hashed_password else: