Skip to content

Commit

Permalink
improve saml config options (superdesk#379)
Browse files Browse the repository at this point in the history
* improve saml config options

we can set on company email domain which will be tested
when authenticating via saml and if user email matches it
it will assign the company to user.

there is also new config for custom client saml configs
which allows us to configure user specific login page which
picks the config to be used for SAML auth. then it uses
the company domain to match the config value.

STTNHUB-222
  • Loading branch information
petrjasek committed Jun 1, 2023
1 parent 55d8088 commit bf9978a
Show file tree
Hide file tree
Showing 14 changed files with 260 additions and 53 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ jobs:
- name: docker-compose
run: docker-compose -f .actions-docker-compose.yml up -d

- run: |
sudo apt-get update
sudo apt-get install pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl
- name: pip install
run: |
python -m pip install --upgrade pip wheel setuptools
Expand Down
3 changes: 3 additions & 0 deletions assets/companies/components/Companies.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ class Companies extends React.Component {
products={this.props.products}
companyTypes={this.props.companyTypes}
apiEnabled={this.props.apiEnabled}
ssoEnabled={this.props.ssoEnabled}
/>
}
</div>
Expand Down Expand Up @@ -133,6 +134,7 @@ Companies.propTypes = {
products: PropTypes.array,
companyTypes: PropTypes.array,
apiEnabled: PropTypes.bool,
ssoEnabled: PropTypes.bool,
showSubscriberId: PropTypes.bool,
companiesById: PropTypes.object,
};
Expand All @@ -151,6 +153,7 @@ const mapStateToProps = (state) => ({
errors: state.errors,
companyTypes: state.companyTypes,
apiEnabled: state.apiEnabled,
ssoEnabled: state.ssoEnabled,
showSubscriberId: companiesSubscriberIdEnabled(state),
});

Expand Down
11 changes: 11 additions & 0 deletions assets/companies/components/EditCompany.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,16 @@ class EditCompany extends React.Component {
defaultOption=""
onChange={this.props.onChange}/>}

{ssoEnabled && (
<TextInput
name='auth_domain'
label={gettext('SSO domain')}
value={company.auth_domain || ''}
onChange={onChange}
error={errors ? errors.auth_domain : null}
/>
)}

<CheckboxInput
labelClass={isInPast(this.props.company.expiry_date) ? 'text-danger' : ''}
name='is_enabled'
Expand Down Expand Up @@ -243,6 +253,7 @@ EditCompany.propTypes = {
fetchCompanyUsers: PropTypes.func.isRequired,
companyTypes: PropTypes.array,
apiEnabled: PropTypes.bool,
ssoEnabled: PropTypes.bool,
originalItem: PropTypes.object,
};

Expand Down
1 change: 1 addition & 0 deletions assets/companies/reducers.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export default function companyReducer(state = initialState, action) {
sections: action.data.sections,
companyTypes: action.data.company_types || [],
apiEnabled: action.data.api_enabled || false,
ssoEnabled: action.data.sso_enabled || false,
ui_config: action.data.ui_config,
};

Expand Down
57 changes: 46 additions & 11 deletions newsroom/auth/saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
"""

import enum
import logging
import pathlib
import superdesk

from typing import Dict, List
Expand All @@ -22,6 +22,8 @@
session,
flash,
url_for,
render_template,
abort,
)
from flask_babel import _
from newsroom.users import UserData
Expand All @@ -34,11 +36,23 @@
SESSION_NAME_ID = "samlNameId"
SESSION_SESSION_ID = "samlSessionIndex"
SESSION_USERDATA_KEY = "samlUserdata"
SESSION_SAML_CLIENT = "_saml_client"

logger = logging.getLogger(__name__)


def init_saml_auth(req):
saml_client = session.get(SESSION_SAML_CLIENT)

if app.config.get("SAML_CLIENTS") and saml_client and saml_client in app.config["SAML_CLIENTS"]:
logging.info("Using SAML config for %s", saml_client)
config_path = pathlib.Path(app.config["SAML_BASE_PATH"]).joinpath(saml_client)
if config_path.exists():
return OneLogin_Saml2_Auth(req, custom_base_path=str(config_path))
logger.error("SAML config not found in %s", config_path)
elif saml_client:
logging.warn("Unknown SAML client %s", saml_client)

auth = OneLogin_Saml2_Auth(req, custom_base_path=str(app.config["SAML_PATH"]))
return auth

Expand All @@ -56,22 +70,35 @@ def prepare_flask_request(request):
}


class UserDataMapping(enum.Enum):
username = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
first_name = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
last_name = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
email = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"


def get_userdata(nameid: str, saml_data: Dict[str, List[str]]) -> UserData:
logger.debug("Attributes for %s = %s", nameid, saml_data)

userdata = UserData(
email=nameid,
first_name=saml_data[UserDataMapping.first_name.value][0],
last_name=saml_data[UserDataMapping.last_name.value][0],
user_type="internal",
)

if app.config.get("SAML_COMPANY"):
for saml_key, user_key in app.config["SAML_USER_MAPPING"].items():
if saml_data.get(saml_key):
userdata[user_key] = saml_data[saml_key][0] # type: ignore

# first we try to find company based on email domain
domain = nameid.split("@")[-1]
if domain:
company = superdesk.get_resource_service("companies").find_one(req=None, auth_domain=domain)
if company is not None:
userdata["company"] = company["_id"]

# then based on preconfigured saml client
if session.get(SESSION_SAML_CLIENT) and not userdata.get("company"):
company = superdesk.get_resource_service("companies").find_one(
req=None, auth_domain=session[SESSION_SAML_CLIENT]
)
if company is not None:
userdata["company"] = company["_id"]

# last option is global env variable
if app.config.get("SAML_COMPANY") and not userdata.get("company"):
company = superdesk.get_resource_service("companies").find_one(req=None, name=app.config["SAML_COMPANY"])
if company is not None:
userdata["company"] = company["_id"]
Expand Down Expand Up @@ -141,3 +168,11 @@ def saml_metadata():
else:
resp = make_response(", ".join(errors), 500)
return resp


@blueprint.route("/login/<client>", methods=["GET"])
def client_login(client):
if not client or client not in app.config["SAML_CLIENTS"]:
return abort(404)
session[SESSION_SAML_CLIENT] = client
return render_template("login_client.html", client=client)
12 changes: 12 additions & 0 deletions newsroom/companies/companies.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ class CompaniesResource(newsroom.Resource):
"allowed_ip_list": {"type": "list", "mapping": {"type": "string"}},
"original_creator": newsroom.Resource.rel("users"),
"version_creator": newsroom.Resource.rel("users"),
"auth_domain": {
"type": "string",
"nullable": True,
},
}
datasource = {"source": "companies", "default_sort": [("name", 1)]}
item_methods = ["GET", "PATCH", "DELETE"]
Expand All @@ -56,6 +60,14 @@ class CompaniesResource(newsroom.Resource):
[("name", 1)],
{"unique": True, "collation": {"locale": "en", "strength": 2}},
),
"auth_domain_1": (
[("auth_domain", 1)],
{
"unique": True,
"collation": {"locale": "en", "strength": 2},
"partialFilterExpression": {"auth_domain": {"$gt": ""}}, # filters out None and ""
},
),
}


Expand Down
2 changes: 2 additions & 0 deletions newsroom/companies/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def get_settings_data():
"company_types": get_company_types_options(app.config.get("COMPANY_TYPES", [])),
"api_enabled": app.config.get("NEWS_API_ENABLED", False),
"ui_config": get_resource_service("ui_config").get_section_config("companies"),
"sso_enabled": bool(app.config.get("SAML_COMPANIES") or app.config.get("SAML_PATH")),
}


Expand Down Expand Up @@ -98,6 +99,7 @@ def get_company_updates(company):
"company_type": company.get("company_type"),
"monitoring_administrator": company.get("monitoring_administrator"),
"allowed_ip_list": company.get("allowed_ip_list"),
"auth_domain": company.get("auth_domain"),
}

if company.get("expiry_date"):
Expand Down
22 changes: 22 additions & 0 deletions newsroom/templates/layout_login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% extends "layout_wire.html" %}

{% block contentMain %}
<div class="container-fluid py-5 overflow-auto">
<div class="row">
<div class="col-12 col-md-8 col-lg-6 col-xxl-4 mx-auto">
<div class="card border-0 bg-white box-shadow--z1">
<div class="card-header pt-4 border-0 bg-white">
<div class="text-center mb-4">
{% include 'login_logo.html' %}
</div>
{% block login_title %}
{% endblock %}
</div>

{% block login_content %}
{% endblock %}
</div>
</div>
</div>
</div>
{% endblock %}
15 changes: 15 additions & 0 deletions newsroom/templates/login_client.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% extends "layout_login.html" %}

{% block login_title %}
<h5 class="mb-0">{{ gettext("Login") }}</h5>
{% endblock %}

{% block login_content %}
<div class="card-body pt-1">
<a href="{{ url_for('auth.saml') }}" title="{{ gettext('Login using Single Sign On') }}" class="btn btn-primary w-100">{{ config.SAML_LABEL }}</a>
</div>

<div class="card-footer bg-white border-0 text-muted small">
<p>{{ gettext("If you have trouble logging in, please contact your own IT support.") }}</p>
</div>
{% endblock %}
16 changes: 16 additions & 0 deletions newsroom/templates/login_locale_dropdown.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{% if get_client_locales()|length is gt 1 %}
<div class="card-body pt-1">
<form class="form" role="form" method="post" action="{{ url_for('auth.set_locale') }}">
<div class="form-group">
<label for="language-select">{{ gettext('Language') }}</label>
<div class="field">
<select id="language-select" name="language" class="form-control" onchange="this.form.submit()">
{% for locale in get_client_locales() %}
<option value="{{ locale.locale }}" {% if locale.locale == get_locale() %}selected{% endif %}>{{ locale.name }}</option>
{% endfor %}
</select>
</div>
</div>
</form>
</div>
{% endif %}
24 changes: 23 additions & 1 deletion newsroom/web/default_settings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import pathlib
from typing import List
import tzlocal
import logging

Expand Down Expand Up @@ -475,7 +476,7 @@
#:
#: .. versionadded:: 2.3
#:
SAML_PATH = ""
SAML_PATH = os.environ.get("SAML_PATH") or ""

#: Company name which will be assigned to newsly created users
#:
Expand All @@ -489,6 +490,27 @@
#:
SAML_LABEL = "SSO"

#: List of available configs for SAML, there should be a folder inside SAML_BASE_PATH for each.
#:
#: .. versionadded:: 2.5
#:
SAML_CLIENTS: List[str] = []

#: Base path for SAML_COMPANIES config
#:
#: .. versionadded:: 2.5
#:
SAML_BASE_PATH: str = os.environ.get("SAML_BASE_PATH") or ""

#: Mapping SAML claims to user data fields.
#:
#: .. versionadded:: 2.5
#:
SAML_USER_MAPPING = {
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname": "first_name",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname": "last_name",
}

#: Rebuild elastic mapping on ``initialize_data`` mapping error
#:
#: .. versionadded:: 2.3
Expand Down
5 changes: 2 additions & 3 deletions requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ eve-elastic==7.3.1
# https://github.com/xhtml2pdf/xhtml2pdf/issues/589
reportlab==3.6.6

elastic-apm[flask]>=6.7,<6.8
MarkupSafe<2.1

superdesk-core @ git+https://github.com/superdesk/superdesk-core.git@v2.6.2-rc1
superdesk-planning @ git+https://github.com/superdesk/superdesk-planning.git@v2.6.0
superdesk-core @ git+https://github.com/superdesk/superdesk-core.git@v2.6.4
superdesk-planning @ git+https://github.com/superdesk/superdesk-planning.git@v2.6.2
Loading

0 comments on commit bf9978a

Please sign in to comment.