Skip to content

Commit

Permalink
Merge branch 'oauth-profiles'
Browse files Browse the repository at this point in the history
  • Loading branch information
guruofgentoo committed Feb 24, 2022
2 parents 0c84a2d + 6d90dfd commit 606c952
Show file tree
Hide file tree
Showing 14 changed files with 568 additions and 31 deletions.
36 changes: 24 additions & 12 deletions docs/source/getting-started.rst
Expand Up @@ -119,21 +119,33 @@ Login Authenticators control validation of users.

- ex. ``uid={},dc=example,dc=org``

- OAuth/OIDC authentication
- OAuth authentication

- ``from keg_auth import OAuthAuthenticator``
- Uses additional dependencies: ``pip install keg-auth[oauth]``
- Leans on ``authlib`` for the OAuth client

- A number of client configurations may be found at https://github.com/authlib/loginpass

- ``from keg_auth import OidcAuthenticator``
- Uses flask-oidc, which needs to be installed: ``pip install keg-auth[oidc]``
- Additional config:

- ``OIDC_PROVIDER_URL``: Target service location.
- ``OIDC_CLIENT_ID``: OAuth ID for the app in the target service.
- ``OIDC_CLIENT_SECRET``: Authenticating secret for app in the target service.
- ``OIDC_AUTH_URI``: OAuth authorize URI. Default "/oauth2/v1/authorize".
- ``OIDC_TOKEN_URI``: OAuth token URI. Default "/oauth2/v1/token".
- ``OIDC_ISSUER``: OAuth issuer location. Default "/oauth2".
- ``OIDC_USERINFO_URI``: OAuth user info URI. Default "/oauth2/userinfo".
- ``KEGAUTH_OIDC_LOGOUT_REDIRECT``: Logout should bypass OAuth logout and just redirect
to this URL. Default None.
- ``KEGAUTH_OAUTH_PROFILES``: list of OAuth provider profile dicts
- Each profile should have the following keys:

- ``domain_filter``: string or list of strings
- ``id_field``: field in the resulting user info to use as the user identity
- ``oauth_client_kwargs``: ``authlib`` client configuration. All of these args will be passed.

- Multiple providers are supported. Login will be served at ``/login/<profile-name>``
- If using a single provider and OAuth will be the only authenticator, consider mapping
``/login`` via the ``RedirectAuthenticator`` and setting ``KEGAUTH_REDIRECT_LOGIN_TARGET``.

- Domain exclusions

- If an OAuth profile is given a domain filter, only user identities within that domain will be
allowed to login via that provider.
- Filtered domains will be disallowed from password login, if ``KegAuthenticator`` is the primary.
- Filtered domains will also prevent a user's domain from being changed in user admin.


.. _gs-loaders:
Expand Down
1 change: 1 addition & 0 deletions keg_auth/__init__.py
Expand Up @@ -5,6 +5,7 @@
JwtRequestLoader,
KegAuthenticator,
LdapAuthenticator,
OAuthAuthenticator,
TokenRequestLoader,
PasswordPolicy,
PasswordPolicyError,
Expand Down
20 changes: 19 additions & 1 deletion keg_auth/core.py
Expand Up @@ -13,6 +13,7 @@
from keg_auth.libs.authenticators import (
DefaultPasswordPolicy,
KegAuthenticator,
OAuthAuthenticator,
)

DEFAULT_CRYPTO_SCHEMES = ('bcrypt', 'pbkdf2_sha256',)
Expand Down Expand Up @@ -52,12 +53,15 @@ class AuthManager(object):
'after-reset': '{blueprint}.login',
'verify-account': '{blueprint}.verify-account',
'after-verify-account': '{blueprint}.login',
'oauth-login': '{blueprint}.oauth-login',
'oauth-authorize': '{blueprint}.oauth-authorize',
}
cli_group_name = 'auth'

def __init__(self, mail_manager=None, blueprint='auth', endpoints=None,
cli_group_name=None, grid_cls=None, login_authenticator=KegAuthenticator,
request_loaders=None, permissions=None, entity_registry=None,
oauth_authenticator=OAuthAuthenticator,
password_policy_cls=DefaultPasswordPolicy):
self.mail_manager = mail_manager
self.blueprint_name = blueprint
Expand All @@ -70,6 +74,7 @@ def __init__(self, mail_manager=None, blueprint='auth', endpoints=None,
self.cli_group = None
self.grid_cls = grid_cls
self.login_authenticator_cls = login_authenticator
self.oauth_authenticator_cls = oauth_authenticator
self.request_loader_cls = tolist(request_loaders or [])
self.request_loaders = dict()
self.menus = dict()
Expand Down Expand Up @@ -116,6 +121,12 @@ def init_config(self, app):
# Use select2 for form selects in templates extending keg_auth/form-base.
app.config.setdefault('KEGAUTH_USE_SELECT2', True)

# Set the login target for the redirect authenticator
app.config.setdefault('KEGAUTH_REDIRECT_LOGIN_TARGET', None)

# OAuth profiles
app.config.setdefault('KEGAUTH_OAUTH_PROFILES', [])

# Set defaults for OIDC URI locations
app.config.setdefault('OIDC_AUTH_URI', '/oauth2/v1/authorize')
app.config.setdefault('OIDC_TOKEN_URI', '/oauth2/v1/token')
Expand Down Expand Up @@ -193,6 +204,9 @@ def init_loaders(self, app):

self.login_authenticator = self.login_authenticator_cls(app)

if app.config.get('KEGAUTH_OAUTH_PROFILES'):
self.oauth_authenticator = OAuthAuthenticator(app)

for loader_cls in self.request_loader_cls:
self.request_loaders[loader_cls.get_identifier()] = loader_cls(app)

Expand Down Expand Up @@ -357,7 +371,11 @@ def create_user(self, user_kwargs, _commit=True):
# which may not be available earlier
user.token_generate()

if mail_enabled and self.mail_manager:
if (
mail_enabled
and self.mail_manager
and not self.login_authenticator.is_domain_excluded(user.username)
):
self.mail_manager.send_new_user(user)

# use add + commit here instead of user_class.add() above so the user isn't actually
Expand Down
20 changes: 19 additions & 1 deletion keg_auth/forms.py
Expand Up @@ -18,6 +18,7 @@
from wtforms_components.widgets import EmailInput

from keg_auth.extensions import lazy_gettext as _
from keg_auth.libs import get_domain_from_email
from keg_auth.libs.templates import link_to
from keg_auth.model import get_username_key

Expand Down Expand Up @@ -144,6 +145,22 @@ def __call__(self, form, field):
return True


class _ValidateUsername:
def __init__(self, username_key=None):
self.username_key = username_key

def __call__(self, form, field):
if form.obj and getattr(form.obj, self.username_key) != field.data:
original_domain = get_domain_from_email(getattr(form.obj, self.username_key))
new_domain = get_domain_from_email(field.data)
is_exclusion = flask.current_app.auth_manager.login_authenticator.is_domain_excluded(
getattr(form.obj, self.username_key)
)
if original_domain and is_exclusion and original_domain != new_domain:
raise ValidationError(_('Cannot change user domain.'))
return True


def user_form(config=None, allow_superuser=False, endpoint='',
fields=['is_enabled', 'disabled_utc']):
"""Returns a form for User CRUD.
Expand Down Expand Up @@ -193,7 +210,8 @@ class FieldsMeta:

setattr(FieldsMeta, username_key, FieldMeta(
extra_validators=[validators.data_required(),
ValidateUnique(html_link)]
ValidateUnique(html_link),
_ValidateUsername(username_key)]
))

if isinstance(flask.current_app.auth_manager.entity_registry.user_cls.username.type,
Expand Down
8 changes: 8 additions & 0 deletions keg_auth/libs/__init__.py
Expand Up @@ -21,3 +21,11 @@ def get_current_user():
if not user or not user.is_authenticated:
return None
return user


def get_domain_from_email(email):
"""Extract domain portion of email address."""
parts = email.split('@')
if len(parts) != 2:
return None
return parts[1]

0 comments on commit 606c952

Please sign in to comment.