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

Feature/OIDC auth #591

Open
wants to merge 2 commits into
base: develop
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
11 changes: 11 additions & 0 deletions .env.model
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,16 @@ IRIS_AUTHENTICATION_TYPE=local
#LDAP_PRIVATE_KEY=
#LDAP_PRIVATE_KEY_PASSWORD=

# -- FOR OIDC AUTHENTICATION
# IRIS_AUTHENTICATION_TYPE=oidc
# OIDC_ISSUER_URL=
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
# endpoints only required if provider doesn't support metadata discovery
# OIDC_AUTH_ENDPOINT=
# OIDC_TOKEN_ENDPOINT=
# optional to include logout from oidc provider
# OIDC_END_SESSION_ENDPOINT=

# -- LISTENING PORT
INTERFACE_HTTPS_PORT=443
4 changes: 4 additions & 0 deletions source/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

from app.flask_dropzone import Dropzone
from app.iris_engine.tasker.celery import make_celery
from app.iris_engine.access_control.oidc_handler import get_oidc_client


class ReverseProxied(object):
Expand Down Expand Up @@ -128,6 +129,9 @@ def ac_current_user_has_manage_perms():
alerts_namespace = AlertsNamespace('/alerts')
socket_io.on_namespace(alerts_namespace)

oidc_client = None
if app.config.get('AUTHENTICATION_TYPE') == "oidc":
oidc_client = get_oidc_client(app)

@app.teardown_appcontext
def shutdown_session(exception=None):
Expand Down
20 changes: 20 additions & 0 deletions source/app/blueprints/pages/dashboard/dashboard_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

from app import app
from app import db
from app import oidc_client
from app.datamgmt.dashboard.dashboard_db import get_tasks_status
from app.forms import CaseGlobalTaskForm
from app.iris_engine.access_control.utils import ac_get_user_case_counts
Expand All @@ -35,6 +36,9 @@
from app.models.models import GlobalTasks
from app.blueprints.access_controls import ac_requires
from app.util import not_authenticated_redirection_url
from app.util import is_authentication_oidc

from oic.oauth2.exception import GrantError

dashboard_blueprint = Blueprint(
'index',
Expand All @@ -55,6 +59,22 @@ def logout():
current_user.ctx_human_case = session['current_case']['case_name']
db.session.commit()

if is_authentication_oidc():
if oidc_client.provider_info["end_session_endpoint"]:
try:
logout_request = oidc_client.construct_EndSessionRequest(state=session["oidc_state"])
logout_url = logout_request.request(oidc_client.provider_info["end_session_endpoint"])
track_activity("user '{}' has been logged-out".format(current_user.user), ctx_less=True, display_in_ui=False)
logout_user()
session.clear()
return redirect(logout_url)
except GrantError:
track_activity(
f"no oidc session found for user '{current_user.user}', skipping oidc provider logout and continuing to logout local user",
ctx_less=True,
display_in_ui=False
)

track_activity("user '{}' has been logged-out".format(current_user.user), ctx_less=True, display_in_ui=False)
logout_user()
session.clear()
Expand Down
82 changes: 80 additions & 2 deletions source/app/blueprints/pages/login/login_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
import pyotp
import qrcode
from urllib.parse import urlsplit
import random
import string

from oic import rndstr
from oic.oic.message import AuthorizationResponse

from flask import Blueprint, flash
from flask import redirect
Expand All @@ -34,6 +39,7 @@
from app import app
from app import bc
from app import db
from app import oidc_client
from app.datamgmt.manage.manage_srv_settings_db import get_server_settings_as_dict

from app.forms import LoginForm, MFASetupForm
Expand All @@ -42,7 +48,9 @@
from app.iris_engine.utils.tracker import track_activity
from app.models.cases import Cases
from app.util import is_authentication_ldap
from app.util import is_authentication_oidc
from app.datamgmt.manage.manage_users_db import get_active_user_by_login
from app.datamgmt.manage.manage_users_db import create_user


login_blueprint = Blueprint(
Expand All @@ -67,9 +75,10 @@ def _render_template_login(form, msg):
organisation_name = app.config.get('ORGANISATION_NAME')
login_banner = app.config.get('LOGIN_BANNER_TEXT')
ptfm_contact = app.config.get('LOGIN_PTFM_CONTACT')
auth_type = app.config.get('AUTHENTICATION_TYPE')

return render_template('login.html', form=form, msg=msg, organisation_name=organisation_name,
login_banner=login_banner, ptfm_contact=ptfm_contact)
login_banner=login_banner, ptfm_contact=ptfm_contact, auth_type=auth_type)


def _validate_local_login(username, password):
Expand Down Expand Up @@ -131,7 +140,7 @@ def _authenticate_password(form, username, password):


# Authenticate user
if app.config.get("AUTHENTICATION_TYPE") in ["local", "ldap"]:
if app.config.get("AUTHENTICATION_TYPE") in ["local", "ldap", "oidc"]:
@login_blueprint.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
Expand All @@ -152,6 +161,75 @@ def login():

return _authenticate_password(form, username, password)

if is_authentication_oidc():
@login_blueprint.route('/oidc-login')
def oidc_login():
if current_user.is_authenticated:
return redirect(url_for('index.index'))

session["oidc_state"] = rndstr()
session["oidc_nonce"] = rndstr()

args = {
"client_id": oidc_client.client_id,
"response_type": "code",
"scope": app.config.get("OIDC_SCOPES"),
"nonce": session["oidc_nonce"],
"redirect_uri": url_for("login.oidc_authorise", _external=True),
"state": session["oidc_state"]
}

auth_req = oidc_client.construct_AuthorizationRequest(request_args=args)
login_url = auth_req.request(oidc_client.authorization_endpoint)

return redirect(login_url)

if is_authentication_oidc():
@login_blueprint.route('/oidc-authorise')
def oidc_authorise():
auth_resp = oidc_client.parse_response(AuthorizationResponse, info=request.args,
sformat="dict")

if auth_resp["state"] != session["oidc_state"]:
track_activity(
f"OIDC session state '{auth_resp['state']}' does not match authorization state '{session['oidc_state']}'",
ctx_less=True,
display_in_ui=False,
)
return redirect(url_for("login.login"))

args = {
"code": auth_resp["code"],
}

access_token_resp = oidc_client.do_access_token_request(state=auth_resp["state"], request_args=args)

# not all providers set email by default, use preferred_username where it's missing
user_login = access_token_resp['id_token'].get("email") or access_token_resp['id_token'].get("preferred_username")
user_name = access_token_resp['id_token'].get("preferred_username") or access_token_resp['id_token'].get("email")

user = _retrieve_user_by_username(user_login)

if not user:
track_activity(
f"Creating OIDC user {user_login} in database",
ctx_less=True,
display_in_ui=False,
)

# generate random password
password = ''.join(random.choices(string.printable[:-6], k=16))

user = create_user(
user_name=user_name,
user_login=user_login,
user_email=user_login,
user_password=bc.generate_password_hash(password.encode('utf8')).decode('utf8'),
user_active=True,
user_is_service_account=False
)

return wrap_login_user(user)

def wrap_login_user(user):

Expand Down
56 changes: 30 additions & 26 deletions source/app/blueprints/pages/login/templates/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,37 +22,41 @@ <h3 class="text-white">{{ organisation_name }}</h3>
<div class="login__right_part col-xs-12 col-md-4">
<h3 class="login__form_title">Sign In</h3>

<form method="post" action="" class="login__form">
{{ form.hidden_tag() }}
<div class="login__form">
<div class="login__form_field_container">
<label for="username"><b>Username</b></label>
<input id="username" name="username" type="text" class="form-control" required="">
</div>
<div class="login__form_field_container">
<label for="password"><b>Password</b></label>
<div class="login__form_input">
<input id="password" name="password" type="password" class="form-control" required="">
<div class="login__show_password" id="togglePassword">
<i class="icon-eye"></i>
{% if auth_type == "oidc" %}
<a href="{{ url_for('login.oidc_login') }}" class="btn btn-primary login__submit_button login__form_title">OIDC Sign In</a>
{% else %}
<form method="post" action="" class="login__form">
{{ form.hidden_tag() }}
<div class="login__form">
<div class="login__form_field_container">
<label for="username"><b>Username</b></label>
<input id="username" name="username" type="text" class="form-control" required="">
</div>
<div class="login__form_field_container">
<label for="password"><b>Password</b></label>
<div class="login__form_input">
<input id="password" name="password" type="password" class="form-control" required="">
<div class="login__show_password" id="togglePassword">
<i class="icon-eye"></i>
</div>
</div>
</div>
{% if msg %}
<div class="alert alert-danger"> <b>Error:</b> {{ msg }} </div>
{% endif %}
<div class="login__form_field_container">
<button type="submit" class="btn btn-primary login__submit_button">Sign In</button>
</div>
</div>
{% if msg %}
<div class="alert alert-danger"> <b>Error:</b> {{ msg }} </div>
</form>

<div class="login__contact_container">
{% if ptfm_contact %}
<span>Don't have an account yet ?</span><br/>
{{ ptfm_contact }}
{% endif %}
<div class="login__form_field_container">
<button type="submit" class="btn btn-primary login__submit_button">Sign In</button>
</div>
</div>
</form>

<div class="login__contact_container">
{% if ptfm_contact %}
<span>Don't have an account yet ?</span><br/>
{{ ptfm_contact }}
{% endif %}
</div>
{% endif %}
</div>
</div>

Expand Down
9 changes: 9 additions & 0 deletions source/app/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,15 @@ class Config:
LDAP_CUSTOM_TLS_CONFIG = config.load('LDAP', 'CUSTOM_TLS_CONFIG', fallback='True')
LDAP_CUSTOM_TLS_CONFIG = (LDAP_CUSTOM_TLS_CONFIG == 'True')

elif authentication_type == 'oidc':
OIDC_ISSUER_URL = config.load('OIDC', 'ISSUER_URL')
OIDC_CLIENT_ID = config.load('OIDC', 'CLIENT_ID')
OIDC_CLIENT_SECRET = config.load('OIDC', 'CLIENT_SECRET')
OIDC_AUTH_ENDPOINT = config.load('OIDC', 'AUTH_ENDPOINT', fallback=None)
OIDC_TOKEN_ENDPOINT = config.load('OIDC', 'TOKEN_ENDPOINT', fallback=None)
OIDC_END_SESSION_ENDPOINT = config.load('OIDC', 'END_SESSION_ENDPOINT', fallback=None)
OIDC_SCOPES = "openid email profile"

""" Caching
"""
CACHE_TYPE = "SimpleCache"
Expand Down
50 changes: 50 additions & 0 deletions source/app/iris_engine/access_control/oidc_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# IRIS Source Code
# [email protected]
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3 of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

# OIDC Configuration
from oic.oic import Client
from oic.utils.authn.client import CLIENT_AUTHN_METHOD
from oic.oic.message import RegistrationResponse
from oic.oic.message import ProviderConfigurationResponse


def get_oidc_client(app) -> Client:
client = Client(client_authn_method=CLIENT_AUTHN_METHOD)

# retrieve provider configuration dynamically from metadata
# or fall back to env vars
try:
client.provider_config(app.config.get("OIDC_ISSUER_URL"))
except Exception as e:
app.logger.warning(f"Could not read OIDC metadata, using environment variables - error {e}")
op_info = ProviderConfigurationResponse(
issuer=app.config.get("OIDC_ISSUER_URL"),
authorization_endpoint=app.config.get("OIDC_AUTH_ENDPOINT"),
token_endpoint=app.config.get("OIDC_TOKEN_ENDPOINT"),
end_session_endpoint=app.config.get("OIDC_END_SESSION_ENDPOINT"),
)

client.handle_provider_config(op_info, op_info['issuer'])

info = {
"client_id": app.config.get("OIDC_CLIENT_ID"),
"client_secret": app.config.get("OIDC_CLIENT_SECRET")
}
client_reg = RegistrationResponse(**info)
client.store_registration_info(client_reg)

return client
10 changes: 8 additions & 2 deletions source/app/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,8 @@ def not_authenticated_redirection_url(request_url: str):
redirection_mapper = {
"oidc_proxy": lambda: app.config.get("AUTHENTICATION_PROXY_LOGOUT_URL"),
"local": lambda: url_for('login.login', next=request_url),
"ldap": lambda: url_for('login.login', next=request_url)
"ldap": lambda: url_for('login.login', next=request_url),
"oidc": lambda: url_for('login.login', next=request_url,),
}

return redirection_mapper.get(app.config.get("AUTHENTICATION_TYPE"))()
Expand All @@ -350,7 +351,8 @@ def is_user_authenticated(incoming_request: Request):
authentication_mapper = {
"oidc_proxy": _oidc_proxy_authentication_process,
"local": _local_authentication_process,
"ldap": _local_authentication_process
"ldap": _local_authentication_process,
"oidc": _local_authentication_process,
}

return authentication_mapper.get(app.config.get("AUTHENTICATION_TYPE"))(incoming_request)
Expand All @@ -364,6 +366,10 @@ def is_authentication_ldap():
return app.config.get('AUTHENTICATION_TYPE') == "ldap"


def is_authentication_oidc():
return app.config.get('AUTHENTICATION_TYPE') == "oidc"


def regenerate_session():
user_data = session.get('user_data', {})

Expand Down
Binary file not shown.
3 changes: 2 additions & 1 deletion source/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,14 @@ pyotp==2.9.0
graphene==3.3
qrcode[pil]==7.4.2
dictdiffer==0.2.0
oic==1.7.0
# unfortunately we are relying on a beta version here. I hope a definitive version gets released soon
graphql-server[flask]==3.0.0b7
graphene-sqlalchemy==3.0.0rc1

dependencies/docx_generator-0.8.0-py3-none-any.whl
dependencies/iris_interface-1.2.0-py3-none-any.whl
dependencies/evtx2splunk-2.0.1-py3-none-any.whl
dependencies/evtx2splunk-2.0.2-py3-none-any.whl
dependencies/iris_evtx-1.2.0-py3-none-any.whl
dependencies/iris_check_module-1.0.1-py3-none-any.whl
dependencies/iris_vt_module-1.2.1-py3-none-any.whl
Expand Down