Skip to content

Commit

Permalink
Merge pull request #104 from level12/oidc-related-updates
Browse files Browse the repository at this point in the history
OIDC and related updates
  • Loading branch information
guruofgentoo committed Mar 24, 2020
2 parents 80ad2c8 + 573c0ea commit fab68f5
Show file tree
Hide file tree
Showing 7 changed files with 312 additions and 250 deletions.
52 changes: 2 additions & 50 deletions keg_auth/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,6 @@ def get_selected_groups(self):


class PermissionsMixin(object):
select_deselect_all = MultiCheckboxField(
'Bulk Permission Action (takes effect after submit)',
choices=(
('select_all', 'Select All'),
('deselect_all', 'Deselect All')
),
render_kw={'class': 'list-unstyled', 'style': 'margin-bottom:0;'},
)
permission_ids = MultiCheckboxField(
'Permissions',
render_kw={'class': 'list-unstyled'},
Expand All @@ -110,10 +102,6 @@ def after_init(self, args, kwargs):

def get_selected_permissions(self):
selected_ids = self.permission_ids.data
if 'select_all' in self.select_deselect_all.data:
selected_ids = [choice[0] for choice in self.permission_ids.choices]
elif 'deselect_all' in self.select_deselect_all.data:
selected_ids = []
return entities_from_ids(flask.current_app.auth_manager.entity_registry.permission_cls,
selected_ids)

Expand Down Expand Up @@ -170,7 +158,7 @@ class FieldsMeta:
is_superuser = FieldMeta('Superuser')
__default__ = FieldMeta

field_order = tuple(_fields + ['group_ids', 'bundle_ids', 'select_deselect_all',
field_order = tuple(_fields + ['group_ids', 'bundle_ids',
'permission_ids'])

setattr(FieldsMeta, username_key, FieldMeta(
Expand All @@ -197,18 +185,6 @@ def get_object_by_field(self, field):
def obj(self):
return self._obj

def validate(self):
if not ModelForm.validate(self):
return False

if 'select_all' in self.select_deselect_all.data and 'deselect_all' in self.select_deselect_all.data: # noqa
error_list = list(self.select_deselect_all.errors)
error_list.append('You may not select all and deselect all. Please choose one.')
self.select_deselect_all.errors = tuple(error_list)
return False

return True

def __iter__(self):
order = ('csrf_token', ) + self.field_order
return (getattr(self, field_id) for field_id in order)
Expand All @@ -224,7 +200,7 @@ def html_link(obj):
return link_to(obj.name, flask.url_for(endpoint, objid=obj.id))

class Group(PermissionsMixin, BundlesMixin, ModelForm):
_field_order = ('name', 'bundle_ids', 'select_deselect_all', 'permission_ids',)
_field_order = ('name', 'bundle_ids', 'permission_ids',)

class Meta:
model = group_cls
Expand All @@ -239,18 +215,6 @@ def get_object_by_field(self, field):
def obj(self):
return self._obj

def validate(self):
if not ModelForm.validate(self):
return False

if 'select_all' in self.select_deselect_all.data and 'deselect_all' in self.select_deselect_all.data: # noqa
error_list = list(self.select_deselect_all.errors)
error_list.append('You may not select all and deselect all. Please choose one.')
self.select_deselect_all.errors = tuple(error_list)
return False

return True

return Group


Expand All @@ -275,16 +239,4 @@ def get_object_by_field(self, field):
def obj(self):
return self._obj

def validate(self):
if not ModelForm.validate(self):
return False

if 'select_all' in self.select_deselect_all.data and 'deselect_all' in self.select_deselect_all.data: # noqa
error_list = list(self.select_deselect_all.errors)
error_list.append('You may not select all and deselect all. Please choose one.')
self.select_deselect_all.errors = tuple(error_list)
return False

return True

return Bundle
147 changes: 135 additions & 12 deletions keg_auth/libs/authenticators.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,21 @@ class UserResponderMixin(object):

def on_inactive_user(self, user):
if flask.current_app.auth_manager.mail_manager and not user.is_verified:
message, category = self.flash_unverified_user
flash(message.format(user.email), category)
if self.flash_unverified_user:
message, category = self.flash_unverified_user
flash(message.format(user.email), category)
if not user.is_enabled:
self.on_disabled_user(user)

def on_invalid_user(self, username):
message, category = self.flash_invalid_user
flash(message.format(username), category)
if self.flash_invalid_user:
message, category = self.flash_invalid_user
flash(message.format(username), category)

def on_disabled_user(self, user):
message, category = self.flash_disabled_user
flash(message.format(user.display_value), category)
if self.flash_disabled_user:
message, category = self.flash_disabled_user
flash(message.format(user.display_value), category)


class LoginResponderMixin(UserResponderMixin):
Expand All @@ -159,7 +162,8 @@ def is_safe_url(target):

def on_success(self, user):
flask_login.login_user(user)
flash(*self.flash_success)
if self.flash_success:
flash(*self.flash_success)

# support Flask-Login "next" parameter
next_parameter = flask.request.values.get('next')
Expand All @@ -180,7 +184,8 @@ class FormResponderMixin(object):
page_title = None

def on_form_error(self, form):
flash(*self.flash_form_error)
if self.flash_form_error:
flash(*self.flash_form_error)

def on_form_valid(self, form):
raise NotImplementedError # pragma: no cover
Expand Down Expand Up @@ -227,7 +232,8 @@ def __call__(self, *args, **kwargs):
return super(PasswordSetterResponderBase, self).__call__(*args, **kwargs)

def flash_and_redirect(self, flash_parts, auth_ident):
flash(*flash_parts)
if flash_parts:
flash(*flash_parts)
redirect_to = flask.current_app.auth_manager.url_for(auth_ident)
flask.abort(flask.redirect(redirect_to))

Expand Down Expand Up @@ -300,7 +306,8 @@ def on_form_valid(self, form):
self.on_invalid_password()

def on_invalid_password(self):
flash(*self.flash_invalid_password)
if self.flash_invalid_password:
flash(*self.flash_invalid_password)


class ForgotPasswordViewResponder(UserResponderMixin, FormResponderMixin, ViewResponder):
Expand Down Expand Up @@ -330,7 +337,8 @@ def on_form_valid(self, form):

def on_success(self, user):
self.send_email(user)
flash(*self.flash_success)
if self.flash_success:
flash(*self.flash_success)
redirect_to = flask.current_app.auth_manager.url_for('after-forgot')
return flask.redirect(redirect_to)

Expand All @@ -345,7 +353,8 @@ class LogoutViewResponder(ViewResponder):

def get(self):
flask_login.logout_user()
flash(*self.flash_success)
if self.flash_success:
flash(*self.flash_success)
redirect_to = flask.current_app.auth_manager.url_for('after-logout')
flask.abort(flask.redirect(redirect_to))

Expand Down Expand Up @@ -453,6 +462,120 @@ def verify_password(self, user, password):
return False


class OidcLoginViewResponder(LoginResponderMixin, ViewResponder):
""" OIDC logins, using an oauth token"""

flash_success = None
page_title = 'Log In'
template_name = 'keg_auth/flash-messages-only.html'

def get(self, *args, **kwargs):
oidc = flask.current_app.auth_manager.oidc
oidc_check = oidc.require_login(lambda: True)()
if oidc_check is not True:
return oidc_check

login_id = oidc.user_getfield("preferred_username")

try:
user = self.parent.verify_user(login_id=login_id)

# User is active and password is verified
return self.on_success(user)
except UserNotFound:
self.on_invalid_user(login_id)
except UserInactive as exc:
self.on_inactive_user(exc.user)

def post(self, *args, **kwargs):
return flask.abort(405)


class OidcLogoutViewResponder(LogoutViewResponder):
""" OIDC logout requires some extra leg-work, because token gets refreshed server-side"""

def get(self):
oidc = flask.current_app.auth_manager.oidc
url_login = flask.url_for(flask.current_app.auth_manager.endpoint('login'))
url_after_login = flask.url_for(flask.current_app.auth_manager.endpoint('after-login'))
bad_token_redirect_resp = flask.current_app.login_manager.unauthorized()

""" Logout won't work if user isn't authenticated to begin with, i.e. there won't be a
token to use. Just redirect to a sane place to force a login to continue."""
try:
user_sub = oidc.user_getfield('sub')
except Exception as exc:
if 'User was not authenticated' not in str(exc):
raise
return flask.abort(flask.redirect(url_login))

""" In some cases e.g. app restart, credentials store may not have valid information in the
flask server-side info. In that case, clear the client token and refresh info from
the oauth source. We have to have a valid id token to make logout work."""
try:
from oauth2client.client import OAuth2Credentials
id_token = OAuth2Credentials.from_json(
oidc.credentials_store[user_sub]
).token_response['id_token']
except KeyError:
oidc.logout()
return flask.abort(bad_token_redirect_resp)

""" Build the oauth request URI, which has to include the ID token. But, logout all client
session info before redirecting there."""
logout_request = '{}{}?id_token_hint={}&post_logout_redirect_uri={}'.format(
flask.current_app.config.get('OIDC_PROVIDER_URL'),
flask.current_app.config.get('OIDC_LOGOUT'),
str(id_token),
flask.current_app.config.get('OIDC_REDIRECT_BASE') + url_after_login,
)
oidc.logout()
flask_login.logout_user()
return flask.redirect(logout_request)


class OidcAuthenticator(LoginAuthenticator):
""" Uses OIDC authentication with an oauth provider, validates against keg-auth db"""
responder_cls = {
'login': OidcLoginViewResponder,
'logout': OidcLogoutViewResponder,
}

def __init__(self, app):
from flask_oidc import OpenIDConnect

oidc_settings = {
'web': {
'client_id': app.config.get('OIDC_CLIENT_ID'),
'client_secret': app.config.get('OIDC_CLIENT_SECRET'),
'auth_uri': app.config.get('OIDC_PROVIDER_URL') + '/oauth2/default/v1/authorize',
'token_uri': app.config.get('OIDC_PROVIDER_URL') + '/oauth2/default/v1/token',
'issuer': app.config.get('OIDC_PROVIDER_URL') + '/oauth2/default',
'userinfo_uri': app.config.get('OIDC_PROVIDER_URL') + '/oauth2/default/userinfo',
'redirect_uris': [
app.config.get('OIDC_REDIRECT_BASE') + app.config.get('OIDC_CALLBACK_ROUTE')
]
}
}

class KAOpenIDConnect(OpenIDConnect):
def load_secrets(self, app):
return oidc_settings
app.auth_manager.oidc = KAOpenIDConnect(app)

super().__init__(app)

def verify_user(self, login_id=None):
user = self.user_ent.query.filter_by(username=login_id).one_or_none()

if not user:
raise UserNotFound
if not user.is_active:
raise UserInactive(user)

return user


class JwtRequestLoader(TokenLoaderMixin, RequestLoader):
""" Loader for JWT tokens contained in the Authorization header.
Expand Down
7 changes: 7 additions & 0 deletions keg_auth/templates/keg_auth/flash-messages-only.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% extends config.get('BASE_TEMPLATE') or config['KEGAUTH_BASE_TEMPLATE'] %}

{% block title %}{{ page_title }} | {{ super() }}{% endblock %}

{% block page_content %}
{# Show any flash messages. #}
{% endblock %}
3 changes: 3 additions & 0 deletions keg_auth/templates/keg_auth/form-base.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% import 'keg_elements/forms/horizontal.html' as horizontal %}
{% extends config.get('BASE_TEMPLATE') or config['KEGAUTH_BASE_TEMPLATE'] %}

{% block title %}{{ page_title }} | {{ super() }}{% endblock %}
Expand All @@ -7,13 +8,15 @@
{% if (use_select2 == true) %}
{% include 'keg_auth/select2-scripts.html' %}
{% endif %}
{{ horizontal.custom_js() }}
{% endblock %}

{% block styles %}
{{ super() }}
{% if (use_select2 == true) %}
{% include 'keg_auth/select2-styles.html' %}
{% endif %}
{{ horizontal.custom_css() }}
{% endblock %}

{% block page_content %}
Expand Down

0 comments on commit fab68f5

Please sign in to comment.