Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[All] Authorize allowed_users, admin_users, _or_ other allowed/admin groups #594

Merged
merged 57 commits into from
Jun 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
6bde66e
Fix generic admin_groups bug
GeorgianaElena Apr 10, 2023
3309dd5
Update openshift also
GeorgianaElena Apr 10, 2023
b680635
Update google tests
GeorgianaElena Apr 11, 2023
d5f9bdf
Fix google auth groups logic
GeorgianaElena Apr 11, 2023
0c717c1
Move static method in base class
GeorgianaElena Apr 11, 2023
a807e18
User should be authorized also if only in admin groups
GeorgianaElena Apr 11, 2023
6290b7b
Update missing funcs
GeorgianaElena Apr 11, 2023
e11edea
Add test for the bug in openshift
GeorgianaElena Apr 11, 2023
ba14061
Update func signature as no longer needed
GeorgianaElena Apr 11, 2023
6e52be2
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 11, 2023
ca2e466
Authorize based on group membership if allowed_users is not set
GeorgianaElena Apr 11, 2023
4fda822
Update authorization funcs
GeorgianaElena Apr 12, 2023
1e5992a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 12, 2023
e43a9c2
Fix typo
GeorgianaElena Apr 12, 2023
554fd76
Check if user was allowed through before checking groups
GeorgianaElena Apr 12, 2023
43f60ae
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 12, 2023
15d62f8
Fix typo
GeorgianaElena Apr 12, 2023
3c25adb
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 12, 2023
c5b3762
Rework attempt before considering overriding Authenticator.check_allowed
consideRatio Apr 23, 2023
41a0db2
Draft work with check_allowed
consideRatio Apr 23, 2023
a3cf070
Additional progress on cilogon and mediawiki
consideRatio Apr 25, 2023
071c411
Update tests
consideRatio Apr 25, 2023
bfaaa11
Fixes from running tests
consideRatio Apr 25, 2023
b8227ac
Add and update fixme comments while developing
consideRatio Apr 25, 2023
91dfb03
Update where admin status is set and considered
consideRatio Apr 26, 2023
6d16946
Update cilogon to reflect latest discussions about taking priority o…
GeorgianaElena May 1, 2023
26065b8
Change user_is_authorised for check_allowed in github
GeorgianaElena May 1, 2023
effc9fd
Change user_is_authorised for check_allowed in gitlab
GeorgianaElena May 1, 2023
d5a3dd9
Reorder
GeorgianaElena May 1, 2023
9afefa2
Change user_is_authorised for check_allowed in globus
GeorgianaElena May 2, 2023
35338a6
Add note about updating auth_model in GitHub
GeorgianaElena May 2, 2023
fcfe088
Small fixes
GeorgianaElena May 2, 2023
980fb5e
Change user_is_authorised for check_allowed for google
GeorgianaElena May 3, 2023
93b14a9
Raise a 403 instead of 500 when logging in using an unallowed domain
GeorgianaElena Jun 13, 2023
c9bbbca
Use username var directly since it's available
GeorgianaElena Jun 13, 2023
98692b1
Fix comment
GeorgianaElena Jun 13, 2023
ec139e1
Fix typo
GeorgianaElena Jun 13, 2023
97cc182
Rm extra quote
GeorgianaElena Jun 13, 2023
a982e1b
Fix group related comment
GeorgianaElena Jun 13, 2023
11b35cb
Move comment to a place where it makes more sense
GeorgianaElena Jun 13, 2023
b7b87d3
Rm todo comment
GeorgianaElena Jun 13, 2023
1dcb1ee
Remove fixme and track it in a todo item
GeorgianaElena Jun 13, 2023
fb12f5f
Set admin status exclusively in for all authenticators
GeorgianaElena Jun 13, 2023
0d21f60
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 13, 2023
25dc2ae
Update comment to match implementation
GeorgianaElena Jun 13, 2023
b44d64b
Set version from membership check func
GeorgianaElena Jun 13, 2023
f1f9d7e
Add note about func not being part of the base class
GeorgianaElena Jun 13, 2023
cdd23e2
Add email to default check list of claims when specifics are not set
GeorgianaElena Jun 13, 2023
efa4e8a
Workaround edge case when JupyterHub calls check_allowed during startup
consideRatio Jun 14, 2023
66b35f8
docs: update changelog preliniary
consideRatio Jun 14, 2023
e70dd31
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 14, 2023
26d4c75
refactor: pure update of comments/docstrings/help and whitespace
consideRatio Jun 23, 2023
8556e27
refactor, openshift: plain refactor for readability
consideRatio Jun 23, 2023
3c62101
refactor, github: plain refactor for readability
consideRatio Jun 23, 2023
fd06b47
refactor, google: rename _google_groups_for_user to _fetch_user_groups
consideRatio Jun 23, 2023
8acf905
google, breaking fix: revoke admin status, groups are now sets in aut…
consideRatio Jun 23, 2023
8b138c7
globus, maint: persist globus groups in auth state, and misc refactor
consideRatio Jun 23, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/source/how-to/example-oauthenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ def build_auth_state_dict(self, token_info, user_info):
# Updates `auth_model` dict if any fields have changed or additional information is available
# or returns the unchanged `auth_model`.
# Returns the model unchanged by default.
# Should be overridden to take into account additional checks such as against group/admin/team membership.
# Should be overridden to take into account additional checks such as against group/admin/team membership.
# if the OAuth provider has such a concept
async def update_auth_model(self, auth_model, **kwargs):
async def update_auth_model(self, username, auth_model):
pass


Expand Down
18 changes: 18 additions & 0 deletions docs/source/reference/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ command line for details.

## [Unreleased]

### Breaking changes

- [All] Users are now authorized based on _either_ being part of
`Authenticator.admin_users`, `Authenticator.allowed_users`, an Authenticator
specific allowed team/group/organization, or declared in
`JupyterHub.load_roles` or `JupyterHub.load_groups`.
- [Google] If `GoogleOAuthenticator.admin_google_groups` is configured, users
logging in not explicitly there or in `Authenticator.admin_users` will get
their admin status revoked.
- [Generic, Google] `GenericOAuthenticator.allowed_groups`,
`GenericOAuthenticator.allowed_groups`
`GoogleOAuthenticator.allowed_google_groups`, and
`GoogleOAuthenticator.admin_google_groups` are now Set based configuration
instead of List based configuration. It is still possible to set these with
lists as as they are converted to sets automatically, but anyone reading and
adding entries must now use set logic and not list logic.
- [Google] Authentication state's `google_groups` is now a set, not a list.

(changelog:version-15)=

## 15.0
Expand Down
1 change: 0 additions & 1 deletion oauthenticator/auth0.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,4 @@ def _userdata_url_default(self):


class LocalAuth0OAuthenticator(LocalAuthenticator, Auth0OAuthenticator):

"""A version that mixes in local system user creation"""
74 changes: 50 additions & 24 deletions oauthenticator/bitbucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,38 +40,64 @@ def _userdata_url_default(self):
config=True, help="Automatically allow members of selected teams"
)

async def user_is_authorized(self, auth_model):
access_token = auth_model["auth_state"]["token_response"]["access_token"]
token_type = auth_model["auth_state"]["token_response"]["token_type"]
username = auth_model["name"]

# Check if user is a member of any allowed teams.
# This check is performed here, as the check requires `access_token`.
if self.allowed_teams:
user_in_team = await self._check_membership_allowed_teams(
username, access_token, token_type
)
if not user_in_team:
self.log.warning(f"{username} not in team allowed list of users")
return False

return True

async def _check_membership_allowed_teams(self, username, access_token, token_type):
async def _fetch_user_teams(self, access_token, token_type):
"""
Get user's team memberships via bitbucket's API.
"""
headers = self.build_userdata_request_headers(access_token, token_type)
# We verify the team membership by calling teams endpoint.
next_page = url_concat(
"https://api.bitbucket.org/2.0/workspaces", {'role': 'member'}
)

user_teams = set()
while next_page:
resp_json = await self.httpfetch(next_page, method="GET", headers=headers)
next_page = resp_json.get('next', None)

user_teams = {entry["name"] for entry in resp_json["values"]}
# check if any of the organizations seen thus far are in the allowed list
if len(self.allowed_teams & user_teams) > 0:
user_teams |= {entry["name"] for entry in resp_json["values"]}
return user_teams

async def update_auth_model(self, auth_model):
"""
Fetch and store `user_teams` in auth state if `allowed_teams` is
configured.
"""
if self.allowed_teams:
access_token = auth_model["auth_state"]["token_response"]["access_token"]
token_type = auth_model["auth_state"]["token_response"]["token_type"]
user_teams = await self._fetch_user_teams(access_token, token_type)
auth_model["auth_state"]["user_teams"] = user_teams

return auth_model

async def check_allowed(self, username, auth_model):
"""
Returns True for users allowed to be authorized.

Overrides the OAuthenticator.check_allowed implementation to allow users
either part of `allowed_users` or `allowed_teams`, and not just those
part of `allowed_users`.
"""
# A workaround for JupyterHub<=4.0.1, described in
# https://github.com/jupyterhub/oauthenticator/issues/621
if auth_model is None:
return True

# allow admin users recognized via admin_users or update_auth_model
if auth_model["admin"]:
return True

# if allowed_users or allowed_teams is configured, we deny users not
# part of either
if self.allowed_users or self.allowed_teams:
user_teams = auth_model["auth_state"]["user_teams"]
if username in self.allowed_users:
return True
if any(user_teams & self.allowed_teams):
return True
return False
return False

# otherwise, authorize all users
return True


class LocalBitbucketOAuthenticator(LocalAuthenticator, BitbucketOAuthenticator):
Expand Down
152 changes: 100 additions & 52 deletions oauthenticator/cilogon.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,90 +246,138 @@ def _validate_allowed_idps(self, proposal):
This is useful for linked identities where not all of them return
the primary username_claim.
""",
default_value=["email"],
)

def user_info_to_username(self, user_info):
claimlist = [self.username_claim]
def _get_final_username_claim_list(self, user_info):
"""
The username claims that will be used to determine the hub username can be set through:
- `CILogonOAutnenticator.username_claim`, that can be extended through `CILogonOAutnenticator.additional_username_claims`
or
- `CILogonOAuthenticator.allowed_idps.<idp>.username_claim`, that
will overwrite any value set through CILogonOAuthenticator.username_claim
for this identity provider.

This function returns the username claim list that will be used for the current user trying to login
based on the idp that they have selected. If no `CILogonOAutnenticator.allowed_idps` is set, then
`CILogonOAutnenticator.username_claim` will be used.
"""
username_claims = [self.username_claim]
if self.additional_username_claims:
claimlist.extend(self.additional_username_claims)

username_claims.extend(self.additional_username_claims)
if self.allowed_idps:
selected_idp = user_info.get("idp")
if selected_idp:
selected_idp = user_info["idp"]
if selected_idp in self.allowed_idps.keys():
# The username_claim which should be used for this idp
claimlist = [
return [
self.allowed_idps[selected_idp]["username_derivation"][
"username_claim"
]
]
else:
return username_claims
return username_claims

for claim in claimlist:
def _get_username_from_claim_list(self, user_info, username_claims):
username = None
for claim in username_claims:
username = user_info.get(claim)
if username:
return username
break

return username

def user_info_to_username(self, user_info):
username_claims = self._get_final_username_claim_list(user_info)
username = self._get_username_from_claim_list(user_info, username_claims)

if not username:
user_info_keys = sorted(user_info.keys())
self.log.error(
f"No username claim in the list at {claimlist} was found in the response {user_info_keys}"
f"No username claim in the list at {username_claims} was found in the response {user_info_keys}"
)
raise web.HTTPError(500, "Failed to get username from CILogon")

async def user_is_authorized(self, auth_model):
username = auth_model["name"]
# Check if selected idp was marked as allowed
# Optionally strip idp domain or prefix the username
if self.allowed_idps:
selected_idp = auth_model["auth_state"][self.user_auth_state_key].get("idp")
# Fail hard if idp wasn't allowed
selected_idp = user_info["idp"]
if selected_idp in self.allowed_idps.keys():
username_derivation = self.allowed_idps[selected_idp][
"username_derivation"
]
action = username_derivation.get("action")

if action == "strip_idp_domain":
username = username.split("@", 1)[0]
elif action == "prefix":
prefix = username_derivation["prefix"]
username = f"{prefix}:{username}"

return username

async def check_allowed(self, username, auth_model):
"""
Returns True for authorized users, raises errors for users
denied authorization.

Overrides the `OAuthenticator.check_allowed` implementation to only allow users
logging in using a provider that is part of `allowed_idps`.
Following this, the user must either be part of `allowed_users` or `allowed_domains`
to be authorized if either is configured, otherwise all users are
authorized.
"""
# A workaround for JupyterHub<=4.0.1, described in
# https://github.com/jupyterhub/oauthenticator/issues/621
if auth_model is None:
return True

# allow admin users recognized via admin_users or update_auth_model
if auth_model["admin"]:
return True

if self.allowed_idps:
user_info = auth_model["auth_state"][self.user_auth_state_key]
selected_idp = user_info["idp"]
if selected_idp not in self.allowed_idps.keys():
self.log.error(
f"Trying to login from an identity provider that was not allowed {selected_idp}",
)
raise web.HTTPError(
500,
403,
"Trying to login using an identity provider that was not allowed",
)

allowed_domains = self.allowed_idps[selected_idp].get(
"allowed_domains", None
)
allowed_domains = self.allowed_idps[selected_idp].get("allowed_domains")
if self.allowed_users or allowed_domains:
if username in self.allowed_users:
return True

if allowed_domains:
gotten_name, gotten_domain = username.split('@')
if gotten_domain not in allowed_domains:
raise web.HTTPError(
500,
"Trying to login using a domain that was not allowed",
if allowed_domains:
username_claims = self._get_final_username_claim_list(user_info)
username_with_domain = self._get_username_from_claim_list(
user_info, username_claims
)

user_domain = username_with_domain.split("@", 1)[1]
if user_domain in allowed_domains:
return True
else:
raise web.HTTPError(
403,
"Trying to login using a domain that was not allowed",
)

return False
# Although not recommended, it might be that `allowed_idps` is not specified
# In this case we need to make sure we still check `allowed_users` and don't assume
# everyone should be authorized
elif self.allowed_users:
if username in self.allowed_users:
return True
return False

# otherwise, authorize all users
return True

async def update_auth_model(self, auth_model):
selected_idp = auth_model["auth_state"][self.user_auth_state_key].get("idp")

# Check if the requested username_claim exists in the response from the provider
username = auth_model["name"]

# Check if we need to strip/prefix username
if self.allowed_idps:
username_derivation_config = self.allowed_idps[selected_idp][
"username_derivation"
]
action = username_derivation_config.get("action", None)
allowed_domains = self.allowed_idps[selected_idp].get(
"allowed_domains", None
)

if action == "strip_idp_domain":
gotten_name, gotten_domain = username.split('@')
username = gotten_name
elif action == "prefix":
prefix = username_derivation_config["prefix"]
username = f"{prefix}:{username}"

auth_model["name"] = username
return auth_model


class LocalCILogonOAuthenticator(LocalAuthenticator, CILogonOAuthenticator):

Expand Down
Loading