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

Add OIDC authentication using Flask-pyoidc. #1492

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
# optional features and their list of requirements
extras_require={
# 'featurename': ["req1", "req2", ],
'Flask-pyoidc>=3.14.2', # OIDC SSO support
'pillow': ["pillow"], # successor to PIL; used by image get for scaling/rotating/etc.;
# requires special libs/header to be installed before it can be compiled successfully
'ldap': ["python-ldap"], # used by ldap auth; requires special libs/header
Expand Down
8 changes: 8 additions & 0 deletions src/moin/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ class ItemNameConverter(PathConverter):
app.register_blueprint(serve, url_prefix='/+serve')
clock.stop('create_app register')
clock.start('create_app flask-cache')
if app.cfg['sso']:
from moin.auth.oidc import oidc
oidc.init_app(app)
# 'SimpleCache' caching uses a dict and is not thread safe according to the docs.
cache = Cache(config={'CACHE_TYPE': 'SimpleCache'})
cache.init_app(app)
Expand Down Expand Up @@ -237,6 +240,8 @@ def setup_user():
session.clear()
userobj = auth.setup_from_session()

# BCD: This is the wrong place to handle login and logout.

# then handle login/logout forms
form = request.values.to_dict()
if 'login_submit' in form:
Expand All @@ -248,6 +253,9 @@ def setup_user():
else:
userobj = auth.handle_request(userobj)

# BCD: Getting the session from the user object (after handling a login even)
# is backwards.

# if we still have no user obj, create a dummy:
if not userobj:
userobj = user.User(name=ANON, auth_method='invalid')
Expand Down
133 changes: 131 additions & 2 deletions src/moin/apps/frontend/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

from werkzeug.utils import secure_filename

from flask import request, url_for, flash, Response, make_response, redirect, abort, jsonify
from flask import request, url_for, flash, Response, make_response, redirect, abort, jsonify, session
from flask import current_app as app
from flask import g as flaskg
from flask_babel import format_datetime
Expand All @@ -46,7 +46,7 @@

import pytz
from babel import Locale

from passlib.pwd import genword
from whoosh import sorting
from whoosh.query import Term, Prefix, And, Or, Not, DateRange, Every
from whoosh.query.qcore import QueryError, TermNotFound
Expand All @@ -55,6 +55,7 @@
from moin.i18n import _, L_
from moin.themes import render_template, contenttype_to_class, get_editor_info
from moin.apps.frontend import frontend
from moin.auth.oidc import oidc
from moin.forms import (OptionalText, RequiredText, URL, YourEmail,
RequiredPassword, Checkbox, InlineCheckbox, Select, Names,
Tags, Natural, Hidden, MultiSelect, Enum, Subscriptions, Quicklinks, RadioChoice,
Expand All @@ -69,6 +70,7 @@
from moin.constants.itemtypes import ITEMTYPE_DEFAULT, ITEMTYPE_TICKET
from moin.constants.contenttypes import * # noqa
from moin.constants.rights import SUPERUSER
from moin.user import search_users, User
from moin.utils import crypto, rev_navigation, close_file, show_time
from moin.utils.crypto import make_uuid, hash_hexdigest
from moin.utils.interwiki import url_for_item, split_fqname, CompositeName
Expand Down Expand Up @@ -136,9 +138,11 @@ def robots():
Disallow: /+wanteds/
Disallow: /+orphans/
Disallow: /+register
Disallow: /+register_oidc_idp
Disallow: /+recoverpass
Disallow: /+usersettings
Disallow: /+login
Disallow: /+login_oidc_idp
Disallow: /+logout
Disallow: /+bookmark
Disallow: /+diff/
Expand Down Expand Up @@ -1870,6 +1874,17 @@ class RegistrationForm(Form):
validators = [ValidRegistration()]


class RegistrationSsoForm(Form):
"""a simple user registration form"""
name = 'register_sso'

username = RequiredText.using(label=L_('Username')).with_properties(
placeholder=L_("The login username you want to use"), autofocus=True
)
email = YourEmail
submit_label = L_('Register')


def _using_moin_auth():
"""Check if MoinAuth is being used for authentication.

Expand Down Expand Up @@ -1931,6 +1946,87 @@ def register():
)


@frontend.route('/+register_oidc_idp', methods=['GET', 'POST'])
@oidc.oidc_auth('idp')
def register_oidc_idp():
"""
Flow is like this:
1. GET /+register_sso
2. Decorator will redirect user to IDP *before* this function is called.
3. After OIDC authenticates the user, it will redirect the user back to this function.
4. If there's no form submission, print the form. Let the user enter username and email address.
5. Second time around the decorator will redirect again, and IDP will redirect back quickly.
6. Validate the form submission
7. Create the user.

In the future, when this project wants to support multiple IDPs, it can parameterize
the endpoint, e.g. /+register_oidc/idp1, /+register_oidc/idp2, and a generic registration
page could have links/buttons for each IDP. See flask_pyoidc documentation.
"""
return _register_oidc_using_provider('idp')


def _register_oidc_using_provider(provider):
if app.cfg.registration_only_by_superuser and not getattr(flaskg.user.may, SUPERUSER)():
# deny registration to bots
abort(404)

title_name = _('Register')
oidc_name = session['userinfo']["given_name"]

if request.method in ['GET']:
return render_template(
'register_sso.html',
title_name=title_name,
form=RegistrationSsoForm.from_defaults(),
name=oidc_name,
)

# At this point, it must be a POST because that's all we allow.

# Validate the form
form = RegistrationSsoForm.from_flat(request.form)
if not form.validate():
return render_template(
'register_sso.html',
title_name=title_name,
form=RegistrationSsoForm,
name=oidc_name,
)

# Create the user.
user_kwargs = {
'username': form['username'].value,
'oidc': provider,
'oidc_uid': session['userinfo']['sub'],
'password': genword(length=32), # BCD: Password should not be required.
'email': form['email'].value,
'trusted': True,
}
# BCD: These should be default values of the user. Consider using factory that
# uses application configuration to generate blank users.
if app.cfg.user_email_verification:
user_kwargs['is_disabled'] = True
user_kwargs['verify_email'] = True
msg = user.create_user(**user_kwargs)
if msg:
flash(msg, "error")
else:
# TODO: Remove code duplication here.
if app.cfg.user_email_verification:
u = user.User(auth_username=user_kwargs['username'])
is_ok, msg = u.mail_email_verification()
if is_ok:
flash(_('Account verification required, please see the email we sent to your address.'), "info")
else:
flash(_('An error occurred while sending the verification email: "%(message)s" '
'Please contact an administrator to activate your account.',
message=msg), "error")
else:
flash(_('Account created, please log in now.'), "info")
return redirect(url_for('.show_root'))


@frontend.route('/+verifyemail', methods=['GET'])
def verifyemail():
u = token = None
Expand Down Expand Up @@ -2149,6 +2245,39 @@ def login():
)


@frontend.route('/+login_oidc_idp', methods=['GET', 'POST'])
@oidc.oidc_auth('idp')
def login_oidc_idp():
_login_oidc_using_provider('idp')
return redirect(url_for('.show_root'))


def _login_oidc_using_provider(provider):
info = session['userinfo']
users = search_users(**{
OIDC: provider,
OIDC_UID: info['sub'],
DISABLED: False,
})
if not users:
logging.warning('When logging in, no users were found.')
flash('User does not exist', 'error')
return
# TODO: What if user has multiple moin accounts for this oidc sub.
user = User(uid=users[0].meta['itemid'])
start_session(user, 'oidc', (), True)


def start_session(user: User, auth_method, auth_attribs, trusted):
""" Regardless of authentication mechanism, this function starts a session. """
logging.info(user.itemid + ' has started a sesstion')
session['user.itemid'] = user.itemid
session['user.trusted'] = trusted # The user meta document loaded does not have this info.
session['user.auth_method'] = auth_method
session['user.auth_attribs'] = auth_attribs
session['user.session_token'] = user.get_session_token()


@frontend.route('/+logout')
def logout():
flash(_("You are logged out."), "info")
Expand Down
10 changes: 10 additions & 0 deletions src/moin/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,15 @@ def login_hint(self):
return Markup(msg)


class OidcAuth(BaseAuth):
""" This authentication is different from others and doesn't fit the design pattern. """
def __init__(self, **kw):
super(OidcAuth, self).__init__(**kw)

name = 'oidc'
logout_possible = True


class GivenAuth(BaseAuth):
""" reuse a given authentication, e.g. http basic auth (or any other auth)
done by the web server, that sets REMOTE_USER environment variable.
Expand Down Expand Up @@ -456,6 +465,7 @@ def setup_from_session():
session_token = session['user.session_token']
logging.debug("got from session: {0!r} {1!r} {2!r} {3!r}".format(itemid, trusted, auth_method, auth_attribs))
logging.debug("current auth methods: {0!r}".format(app.cfg.auth_methods))
# BCD: Why do we care about auth method? All that matters is the session_token.
if auth_method and auth_method in app.cfg.auth_methods:
userobj = user.User(itemid,
auth_method=auth_method,
Expand Down
17 changes: 17 additions & 0 deletions src/moin/auth/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from flask_pyoidc import OIDCAuthentication
from flask_pyoidc.provider_configuration import ClientMetadata, ProviderConfiguration

from wikiconfig import OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_LOGOUT_URI


oidc = OIDCAuthentication({
'idp': ProviderConfiguration(
issuer=OIDC_ISSUER,
# static client registration
client_metadata=ClientMetadata(
client_id=OIDC_CLIENT_ID,
client_secret=OIDC_CLIENT_SECRET,
post_logout_redirect_uris=[OIDC_LOGOUT_URI],
),
),
})
2 changes: 2 additions & 0 deletions src/moin/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,8 @@ def __init__(self, exprstr):
LOCALE: None, # None -> do browser language detection, otherwise just use this locale
TIMEZONE: None, # None -> use cfg.timezone_default
EMAIL_UNVALIDATED: None,
OIDC: None,
OIDC_UID: None,
}, 'Default attributes of the user object'),
)),
# ==========================================================================
Expand Down
10 changes: 10 additions & 0 deletions src/moin/config/wikiconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ class Config(DefaultConfig):
# root_mapping = {'user': 'UserHome'}
# default root, use this value by default for all namespaces
default_root = 'Home'
sso = False


# flask settings require all caps
Expand All @@ -256,3 +257,12 @@ class Config(DefaultConfig):
# config for flask-cache:
# CACHE_TYPE = 'filesystem'
# CACHE_DIR = '/path/to/flask-cache-dir'

# Using + (plus) in the OIDC_REDIRECT_URI works on the python side, i.e. it is encoded
# correctly into %2B, but sadly Keycloak converts it to ' '. See
# https://en.wikipedia.org/wiki/Percent-encoding#The_application/x-www-form-urlencoded_type
# OIDC_REDIRECT_URI = 'http://localhost:5000/oidc_redirect_uri'
# OIDC_ISSUER="https://localhost:8080/auth/realms/myfolks"
# OIDC_CLIENT_ID='example.com/wiki' # best practice is to use domain name and path here
# OIDC_CLIENT_SECRET='get this from the idp administrator'
# OIDC_LOGOUT_URI='todo'
3 changes: 3 additions & 0 deletions src/moin/constants/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@
EMAIL_UNVALIDATED = "email_unvalidated"
NAMERE = "namere"
NAMEPREFIX = "nameprefix"
OIDC = "oidc"
OIDC_UID = "oidc_uid"

# in which backend is some revision stored?
BACKENDNAME = "backendname"
Expand All @@ -126,6 +128,7 @@
ISO_8601, MAILTO_AUTHOR, SHOW_COMMENTS, RESULTS_PER_PAGE,
EDIT_ON_DOUBLECLICK, SCROLL_PAGE_AFTER_EDIT,
EDIT_ROWS, THEME_NAME, LOCALE, TIMEZONE, SUBSCRIPTIONS, QUICKLINKS, CSS_URL,
OIDC, OIDC_UID,
]

# keys for blog homepages
Expand Down
2 changes: 2 additions & 0 deletions src/moin/storage/middleware/indexing.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,8 @@ def __init__(self, index_storage, backend, wiki_name=None, acl_rights_contents=[
LOCALE: ID(stored=True),
SUBSCRIPTION_IDS: ID(),
SUBSCRIPTION_PATTERNS: ID(),
OIDC: ID(stored=True),
OIDC_UID: ID(stored=True),
}
latest_revs_fields.update(**userprofile_fields)

Expand Down
2 changes: 2 additions & 0 deletions src/moin/storage/middleware/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,8 @@ def subscription_validator(element, state):
.of(String.named('subscription').validated_by(subscription_validator)).using(optional=True),
List.named(keys.EMAIL_SUBSCRIBED_EVENTS).of(String.named('email_subscribed_event')).using(optional=True),
# TODO: DuckDict.named('bookmarks').using(optional=True),
String.named(keys.OIDC).using(optional=True),
String.named(keys.OIDC_UID).using(optional=True),
*common_meta
)

Expand Down
19 changes: 19 additions & 0 deletions src/moin/templates/register_sso.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{% extends theme("layout.html") %}
{% import "forms.html" as forms %}

{% block content %}
<h1>{{ _("Create Account using SSO") }}</h1>
<p>
{{ name }}, You have already been identified by the Identity Provider. Please continue registration here.
</p>
<div class="moin-form">
{{ gen.form.open(form, method="post", action=url_for('frontend.register_oidc_idp')) }}
{{ forms.render_errors(form) }}
<dl>
{{ forms.render(form['username']) }}
{{ forms.render(form['email']) }}
</dl>
{{ forms.render_submit(form) }}
{{ gen.form.close() }}
</div>
{% endblock %}
14 changes: 14 additions & 0 deletions src/moin/templates/utils.html
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,20 @@
<a href="{{ login_url }}" class="moin-login" rel="nofollow">{{ _('Login') }}</a>
</li>
{%- endif %}
{%- set login_idp_url = theme_supp.login_oidc_idp_url() %}
{% if login_idp_url %}
<li>
{# TODO: translate #}
<a href="{{ login_idp_url }}" class="moin-login" rel="nofollow">Login with SSO</a>
</li>
{% endif %}
{%- set register_idp_url = theme_supp.register_oidc_idp_url() %}
{% if register_idp_url %}
<li>
{# TODO: translate #}
<a href="{{ register_idp_url }}" class="moin-login" rel="nofollow">Register with SSO</a>
</li>
{% endif %}
{%- endif %}
{% endmacro %}

Expand Down
Loading