Merge pull request #6017 from FinnStutzenstein/samlGroups

Adding attribute matchers for group assignments to SAML
This commit is contained in:
Emanuel Schütze 2021-04-27 21:30:38 +02:00 committed by GitHub
commit 672e5ca544
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 115 additions and 29 deletions

View File

@ -35,17 +35,17 @@ folder next to the ``saml_settings.json``.
The following settings are given in the `saml_settings.json`. All entries are required, except for the request settings. The following settings are given in the `saml_settings.json`. All entries are required, except for the request settings.
### General settings ### `generalSettings`
Here you can provide a custom text for the SAML login button. The `changePasswordUrl` Here you can provide a custom text for the SAML login button. The `changePasswordUrl`
redirects the user to the given URL when click on `Change password` in the OpenSlides user redirects the user to the given URL when click on `Change password` in the OpenSlides user
menu. menu.
### Attributes ### `attributeMapping`
The identity provider sends attributes to the server if a user sucessfully logged in. To The identity provider sends attributes to the server if a user successfully logged in. To
map these attributes to attributes of OpenSlides users, the section `attributeMapping` map these attributes to attributes of OpenSlides users, the section `attributeMapping`
exists. The structure is like this:: exists. The structure is like this::
"attributeMapping: { "attributeMapping": {
"attributeFromIDP": ["attributeOfOpenSlidesUser", <used for lookup>], "attributeFromIDP": ["attributeOfOpenSlidesUser", <used for lookup>],
"anotherAttributeFromIDP": ["anotherAttributeOfOpenSlidesUser", <used for lookup>] "anotherAttributeFromIDP": ["anotherAttributeOfOpenSlidesUser", <used for lookup>]
} }
@ -56,7 +56,7 @@ All available OpenSlides user attributes are:
- ``first_name``: The user's first name. - ``first_name``: The user's first name.
- ``last_name``: The user's last name. - ``last_name``: The user's last name.
- ``title``: The title of the user, e.g. "Dr.". - ``title``: The title of the user, e.g. "Dr.".
- ``email``: The user's email addreess. - ``email``: The user's email address.
- ``structure_level``: The structure level. - ``structure_level``: The structure level.
- ``number``: The participant number (text, not an actual number). Note: This field is not unique. - ``number``: The participant number (text, not an actual number). Note: This field is not unique.
- ``about_me``: A free text field. - ``about_me``: A free text field.
@ -71,7 +71,7 @@ created with all values given. Try to choose unique attributes (e.g. the usernam
attributes you are sure about to be unique (e.g. maybe the number) or use a combination of attributes you are sure about to be unique (e.g. maybe the number) or use a combination of
attributes. attributes.
### Requests ### `requestsSettings`
One can overwrite the data extracted from the request headers of saml-requests. E.g. if the public port is 80 and the server is reverse-proxied and listen to port 8000, one should set the `server_port` to 80, so OpenSlides does not take the port of the request header. If not specified all these values are taken from the requests meta information: One can overwrite the data extracted from the request headers of saml-requests. E.g. if the public port is 80 and the server is reverse-proxied and listen to port 8000, one should set the `server_port` to 80, so OpenSlides does not take the port of the request header. If not specified all these values are taken from the requests meta information:
@ -80,8 +80,29 @@ One can overwrite the data extracted from the request headers of saml-requests.
- ``script_name``: The aquivalent to ``PATH_INFO`` in the meta values. - ``script_name``: The aquivalent to ``PATH_INFO`` in the meta values.
- ``server_port``: The port listen by the server. - ``server_port``: The port listen by the server.
### Default group ids ### `groups`
If the optional key `defaultGroupIds` is given, these groups are assigned to The optional key `groups` can contain rules to assign groups to new created users on saml logins.
each new created user on each saml login. It must be a list of ids. To disable
this feature, either just do not inlcude this key, or set it to `null`. First, there is an optional list of matchers (may not be given or empty). Each amtcher matches an attribute against an regex. If an attribute value matches the regex, the groups given in `groups` (list of groups) will be added to the user. This is done for all matchers indipendently, so if multiple matchers matches, all groups are used.
If no matcher matches (also if there is no matcher), the groups in `default_groups` will be used. This key is also optional. Leaving it out or using an empty list will not assign default groups.
An example with two matchers and default groups:
```
"groups": {
"matchers": [
{
"attribute": "attr1",
"regex": "^.*test.*$",
"group_ids": [1]
},
{
"attribute": "attr2",
"regex": "^012.*$",
"group_ids": [2, 3]
}
],
"default_group_ids": [5]
}
```

View File

@ -66,5 +66,7 @@
"UserID": ["username", true], "UserID": ["username", true],
"FirstName": ["first_name", false], "FirstName": ["first_name", false],
"LastName": ["last_name", false] "LastName": ["last_name", false]
} },
"requestSettings": {},
"groups": {}
} }

View File

@ -1,6 +1,7 @@
import json import json
import logging import logging
import os import os
import re
from typing import Dict, Tuple from typing import Dict, Tuple
from django.conf import settings from django.conf import settings
@ -94,7 +95,14 @@ class SamlSettings:
- request_settings: { - request_settings: {
<key>: <value>, <key>: <value>,
} }
- default_group_ids: [<id>, ...] | null | undefined - groups: {
matchers: [{
regex: <regex>,
attribute: <str>,
group_ids: [<id>, ...]
}],
default_group_ids: [<id>, ...] | null | undefined
}
""" """
def __init__(self): def __init__(self):
@ -122,7 +130,7 @@ class SamlSettings:
self.load_general_settings(content) self.load_general_settings(content)
self.load_attribute_mapping(content) self.load_attribute_mapping(content)
self.load_request_settings(content) self.load_request_settings(content)
self.load_default_group_ids(content) self.load_groups(content)
# Load saml settings # Load saml settings
self.saml_settings = OneLogin_Saml2_Settings( self.saml_settings = OneLogin_Saml2_Settings(
@ -213,19 +221,39 @@ class SamlSettings:
] not in ("on", "off"): ] not in ("on", "off"):
raise SamlException('The https value must be "on" or "off"') raise SamlException('The https value must be "on" or "off"')
def load_default_group_ids(self, content): def load_groups(self, content):
self.default_group_ids = content.pop("defaultGroupIds", None) self.groups = content.pop("groups", {})
if self.default_group_ids is None: matchers = self.groups.get("matchers", [])
return for i, m in enumerate(matchers):
if not isinstance(self.default_group_ids, list): if not isinstance(m.get("attribute"), str):
raise SamlException( raise SamlException(f"Attribute of matcher {i+1} must be a string")
"default_group_ids must be null (or not present) or a list of integers" regex = m["regex"]
) if not regex:
for id in self.default_group_ids: raise SamlException(f"Matcher {i+1} does not have a regex")
if not isinstance(id, int): try:
raise SamlException( regex = re.compile(regex)
"default_group_ids must be null (or not present) or a list of integers" except re.error as e:
) raise SamlException(f"The regex for matcher {i+1} is invalid: {str(e)}")
m["regex"] = regex
m["group_ids"] = m.get("group_ids", [])
if not m["group_ids"]:
raise SamlException(f"The group_ids for matcher {i+1} is empty")
self.assert_list_of_ints(m["group_ids"], f"group_ids of matcher {i+1}")
self.groups["matchers"] = matchers
default_group_ids = self.groups.pop("default_group_ids", [])
self.assert_list_of_ints(default_group_ids, "default_group_ids")
self.groups["default_group_ids"] = default_group_ids
logger.info(f"Loaded {len(matchers)} matchers")
logger.info(f"Configured {default_group_ids} as default groups")
def assert_list_of_ints(self, value, name):
if not isinstance(value, list):
raise SamlException(f"{name} must be a list of integers")
for x in value:
if not isinstance(x, int):
raise SamlException(f"Each entry of {name} must be an integer")
saml_settings = None saml_settings = None

View File

@ -143,9 +143,7 @@ class SamlView(View):
logger.info( logger.info(
f"Created new saml user with id {user.id} and username {user.username}" f"Created new saml user with id {user.id} and username {user.username}"
) )
group_ids = get_saml_settings().default_group_ids self.add_groups_to_user(user, attributes)
if group_ids:
user.groups.add(*group_ids)
inform_changed_data(user) # put the new user into the cache inform_changed_data(user) # put the new user into the cache
else: else:
logger.info( logger.info(
@ -154,6 +152,43 @@ class SamlView(View):
self.update_user(user, queryargs["defaults"]) self.update_user(user, queryargs["defaults"])
auth_login(request, user) auth_login(request, user)
def add_groups_to_user(self, user, attributes):
groups = get_saml_settings().groups
group_ids = set()
for i, m in enumerate(groups["matchers"]):
raw_attr_value = attributes.get(m["attribute"], "")
# Convert the raw_attr_value to a list of string.
# It can be a string or list of strings. Other types will be ignored.
attr_values = None
if isinstance(raw_attr_value, str):
attr_values = [raw_attr_value]
elif isinstance(raw_attr_value, list) and all(
isinstance(x, str) for x in raw_attr_value
):
attr_values = raw_attr_value
if attr_values is None:
logger.warning(
f"Want to match matcher {i+1} but the attribute value is not a string or a list of strings but {type(raw_attr_value)}"
)
continue
if any(m["regex"].search(x) for x in attr_values):
group_ids.update(m["group_ids"])
logger.info(f"Matcher {i+1} matched. Adding groups: {m['group_ids']}")
else:
logger.info(f"Matcher {i+1} did not match")
# since a matcher must have groups, not having groups here means no matcher matched
if not group_ids:
logger.info(
f"No matcher matched. Adding default groups: {groups['default_group_ids']}"
)
group_ids = groups["default_group_ids"]
logger.info(f"Adding these groups to the user: {list(group_ids)}")
if group_ids:
user.groups.add(*group_ids)
def get_queryargs(self, attributes): def get_queryargs(self, attributes):
""" """
Build the arguments for getting or creating a user Build the arguments for getting or creating a user