Skip to content

Commit

Permalink
[auth] add saml auth
Browse files Browse the repository at this point in the history
  • Loading branch information
EvanBldy committed Aug 28, 2024
1 parent f0b0801 commit 38646c0
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 21 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
version: ["3.9", "3.10", "3.11", "3.12"]
services:
postgres:
image: "postgres:12.16"
Expand Down
3 changes: 2 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
},
"args": ["run", "--no-reload"],
"jinja": true,
"justMyCode": false
"justMyCode": false,
"envFile": "${workspaceFolder}/OKTA_SAML.env"
},
{
"name": "Python: Current File",
Expand Down
5 changes: 2 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ classifiers =
Environment :: Web Environment
Framework :: Flask
Intended Audience :: Developers
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Expand Down Expand Up @@ -56,7 +55,6 @@ install_requires =
ldap3==2.9.1
matterhook==0.2
meilisearch==0.31.5
numpy==1.24.4; python_version == '3.8'
numpy==2.0.1; python_version == '3.9'
numpy==2.1.0; python_version >= '3.10'
opencv-python==4.10.0.84
Expand All @@ -67,6 +65,7 @@ install_requires =
psutil==6.0.0
psycopg[binary]==3.2.1
pyotp==2.9.0
pysaml2==7.5.0
python-nomad==2.0.1
python-slugify==8.0.4
python-socketio==5.11.3
Expand Down Expand Up @@ -96,7 +95,7 @@ dev =
wheel

test =
fakeredis==2.23.5
fakeredis==2.24.1
mixer==7.2.2
pytest-cov==5.0.0
pytest==8.3.2
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env python
from setuptools import setup

setup(python_requires=">=3.8.1, <3.13")
setup(python_requires=">=3.9, <3.13")
4 changes: 4 additions & 0 deletions zou/app/blueprints/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
RegistrationResource,
ResetPasswordResource,
TOTPResource,
SAMLSSOResource,
SAMLLoginResource,
)

routes = [
Expand All @@ -27,6 +29,8 @@
("/auth/email-otp", EmailOTPResource),
("/auth/recovery-codes", RecoveryCodesResource),
("/auth/fido", FIDOResource),
("/auth/saml/sso", SAMLSSOResource),
("/auth/saml/login", SAMLLoginResource),
]

blueprint = Blueprint("auth", "auth")
Expand Down
90 changes: 89 additions & 1 deletion zou/app/blueprints/auth/resources.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import urllib.parse

from flask import request, jsonify, current_app
from flask import request, jsonify, current_app, redirect, make_response
from flask_restful import Resource
from flask_principal import (
Identity,
Expand All @@ -19,6 +19,7 @@

from sqlalchemy.exc import OperationalError, TimeoutError
from babel.dates import format_datetime
from saml2 import entity

from zou.app import app, config
from zou.app.mixin import ArgsMixin
Expand Down Expand Up @@ -1331,3 +1332,90 @@ def put(self):
},
400,
)


class SAMLSSOResource(Resource, ArgsMixin):
"""
Resource to allow a user to login with SAML SSO.
"""

def post(self):
""" """
authn_response = auth_service.saml_client.parse_authn_request_response(
request.form["SAMLResponse"], entity.BINDING_HTTP_POST
)
authn_response.get_identity()
user_info = authn_response.get_subject()
email = user_info.text

try:
user = persons_service.get_person_by_email(email)
except PersonNotFoundException:
person_info = {
k: v if not isinstance(v, list) else " ".join(v)
for k, v in authn_response.ava.items()
}
user = persons_service.create_person(
email, "default".encode("utf-8"), **person_info
)

access_token = create_access_token(
identity=user["id"],
additional_claims={
"identity_type": "person",
},
)
refresh_token = create_refresh_token(
identity=user["id"],
additional_claims={
"identity_type": "person",
},
)
identity_changed.send(
current_app._get_current_object(),
identity=Identity(user["id"], "person"),
)

ip_address = request.environ.get("HTTP_X_REAL_IP", request.remote_addr)

if is_from_browser(request.user_agent):
response = make_response(
redirect(f"{config.DOMAIN_PROTOCOL}://{config.DOMAIN_NAME}")
)
set_access_cookies(response, access_token)
set_refresh_cookies(response, refresh_token)
events_service.create_login_log(user["id"], ip_address, "web")

# NOTE:
# On a production system, the RelayState MUST be checked
# to make sure it doesn't contain dangerous URLs!
if "RelayState" in request.form:
request.form["RelayState"]
return response


class SAMLLoginResource(Resource, ArgsMixin):
"""
Resource to allow a user to login with SAML SSO.
"""

def get(self):
""" """
reqid, info = auth_service.saml_client.prepare_for_authenticate()

redirect_url = None
# Select the IdP URL to send the AuthN request to
for key, value in info["headers"]:
if key == "Location":
redirect_url = value
response = redirect(redirect_url, code=302)
# NOTE:
# I realize I _technically_ don't need to set Cache-Control or Pragma:
# http://stackoverflow.com/a/5494469
# However, Section 3.2.3.2 of the SAML spec suggests they are set:
# http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
# We set those headers here as a "belt and suspenders" approach,
# since enterprise environments don't always conform to RFCs
response.headers["Cache-Control"] = "no-cache, no-store"
response.headers["Pragma"] = "no-cache"
return response
28 changes: 14 additions & 14 deletions zou/app/blueprints/index/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def get(self):
200:
description: API name and version
"""
return {"api": app.config["APP_NAME"], "version": __version__}
return {"api": config.APP_NAME, "version": __version__}


class BaseStatusResource(Resource):
Expand Down Expand Up @@ -86,10 +86,8 @@ def get_status(self):

version = __version__

api_name = app.config["APP_NAME"]

return (
api_name,
config.APP_NAME,
version,
is_db_up,
is_kv_up,
Expand Down Expand Up @@ -281,20 +279,22 @@ def get(self):
200:
description: Crisp token
"""
config = {
"is_self_hosted": app.config["IS_SELF_HOSTED"],
"crisp_token": app.config["CRISP_TOKEN"],
conf = {
"is_self_hosted": config.IS_SELF_HOSTED,
"crisp_token": config.CRISP_TOKEN,
"indexer_configured": (
len(app.config["INDEXER"]["key"]) > 0
and app.config["INDEXER"]["key"] != "masterkey"
len(config.INDEXER["key"]) > 0
and config.INDEXER["key"] != "masterkey"
),
"saml_enabled": config.SAML_ENABLED,
"saml_idp_name": config.SAML_IDP_NAME,
}
if app.config["SENTRY_KITSU_ENABLED"]:
config["sentry"] = {
"dsn": app.config["SENTRY_KITSU_DSN"],
"sampleRate": app.config["SENTRY_KITSU_SR"],
if config.SENTRY_KITSU_ENABLED:
conf["sentry"] = {
"dsn": config.SENTRY_KITSU_DSN,
"sampleRate": config.SENTRY_KITSU_SR,
}
return config
return conf


class TestEventsResource(Resource):
Expand Down
4 changes: 4 additions & 0 deletions zou/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@
LDAP_IS_AD_SIMPLE = envtobool("LDAP_IS_AD_SIMPLE", False)
LDAP_SSL = envtobool("LDAP_SSL", False)

SAML_ENABLED = envtobool("SAML_ENABLED", False)
SAML_IDP_NAME = os.getenv("SAML_IDP_NAME", "")
SAML_METADATA_URL = os.getenv("SAML_METADATA_URL", "")

LOGS_MODE = os.getenv("LOGS_MODE", "default")
LOGS_HOST = os.getenv("LOGS_HOST", "localhost")
LOGS_PORT = os.getenv("LOGS_PORT", 2202)
Expand Down
58 changes: 58 additions & 0 deletions zou/app/services/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import string
import flask_bcrypt
import fido2.features
import requests

from datetime import timedelta

Expand Down Expand Up @@ -39,6 +40,13 @@
from zou.app.utils import date_helpers, emails
from zou.app import config

from saml2 import (
BINDING_HTTP_POST,
BINDING_HTTP_REDIRECT,
)
from saml2.client import Saml2Client
from saml2.config import Config as Saml2Config

from fido2.webauthn import (
PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity,
Expand Down Expand Up @@ -751,3 +759,53 @@ def logout(jti):
revoke_tokens(current_app, jti)
except Exception:
pass


def saml_client_for(metadata_url):
"""
Given the name of an IdP, return a configuation.
The configuration is a hash for use by saml2.config.Config
"""
acs_url = f"http://{config.DOMAIN_NAME}/api/auth/saml/sso"
https_acs_url = f"https://{config.DOMAIN_NAME}/api/auth/saml/sso"

# TODO: store that in cache instead of fetching it every time
rv = requests.get(metadata_url)

settings = {
"entityid": f"{config.DOMAIN_PROTOCOL}://{config.DOMAIN_NAME}/api/auth/saml/login",
"metadata": {
"inline": [rv.text],
},
"service": {
"sp": {
"endpoints": {
"assertion_consumer_service": [
(acs_url, BINDING_HTTP_REDIRECT),
(acs_url, BINDING_HTTP_POST),
(https_acs_url, BINDING_HTTP_REDIRECT),
(https_acs_url, BINDING_HTTP_POST),
],
},
# Don't verify that the incoming requests originate from us via
# the built-in cache for authn request ids in pysaml2
"allow_unsolicited": True,
# Don't sign authn requests, since signed requests only make
# sense in a situation where you control both the SP and IdP
"authn_requests_signed": False,
"logout_requests_signed": True,
"want_assertions_signed": True,
"want_response_signed": False,
},
},
}
spConfig = Saml2Config()
spConfig.load(settings)
spConfig.allow_unknown_attributes = True
saml_client = Saml2Client(config=spConfig)
return saml_client


saml_client = None
if config.SAML_ENABLED:
saml_client = saml_client_for(config.SAML_METADATA_URL)

0 comments on commit 38646c0

Please sign in to comment.