Skip to content

Commit 25df8c0

Browse files
authored
Merge pull request #4765 from StackStorm/st2auth-sso
Implement SSO plugin for user authentication in StackStorm
2 parents d746d67 + d650ccc commit 25df8c0

File tree

18 files changed

+608
-5
lines changed

18 files changed

+608
-5
lines changed

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,12 @@ requirements: virtualenv .requirements .sdist-requirements install-runners
567567
# make targets. This speeds up the build
568568
(cd ${ROOT_DIR}/st2common; ${ROOT_DIR}/$(VIRTUALENV_DIR)/bin/python setup.py develop --no-deps)
569569

570+
# Install st2auth to register SSO drivers
571+
# NOTE: We pass --no-deps to the script so we don't install all the
572+
# package dependencies which are already installed as part of "requirements"
573+
# make targets. This speeds up the build
574+
(cd ${ROOT_DIR}/st2auth; ${ROOT_DIR}/$(VIRTUALENV_DIR)/bin/python setup.py develop --no-deps)
575+
570576
# Some of the tests rely on submodule so we need to make sure submodules are check out
571577
git submodule update --recursive --remote
572578

conf/st2.conf.sample

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ port = 9101
5353
# Common option - options below apply in both scenarios - when auth service is running as a WSGI
5454
# service (e.g. under Apache or Nginx) and when it's running in the standalone mode.
5555

56+
# JSON serialized arguments which are passed to the SSO backend.
57+
sso_backend_kwargs = None
5658
# Enable authentication middleware.
5759
enable = True
5860
# Path to the logging config.
@@ -63,10 +65,14 @@ api_url = None
6365
service_token_ttl = 86400
6466
# Access token ttl in seconds.
6567
token_ttl = 86400
68+
# Enable Single Sign On for GUI if true.
69+
sso = False
6670
# Authentication mode (proxy,standalone)
6771
mode = standalone
6872
# Specify to enable debug mode.
6973
debug = False
74+
# Single Sign On backend to use when SSO is enabled. Available backends: noop, saml2.
75+
sso_backend = noop
7076

7177
# Standalone mode options - options below only apply when auth service is running in the standalone
7278
# mode.

st2auth/setup.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,10 @@
4646
packages=find_packages(exclude=['setuptools', 'tests']),
4747
scripts=[
4848
'bin/st2auth'
49-
]
49+
],
50+
entry_points={
51+
'st2auth.sso.backends': [
52+
'noop = st2auth.sso.noop:NoOpSingleSignOnBackend'
53+
]
54+
}
5055
)

st2auth/st2auth/app.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from st2common.constants.system import VERSION_STRING
2727
from st2common.service_setup import setup as common_setup
2828
from st2common.util import spec_loader
29+
from st2common.util.monkey_patch import use_select_poll_workaround
2930
from st2auth import config as st2auth_config
3031
from st2auth.validation import validate_auth_backend_is_correctly_configured
3132

@@ -61,6 +62,10 @@ def setup_app(config=None):
6162
capabilities=capabilities,
6263
config_args=config.get('config_args', None))
6364

65+
# pysaml2 uses subprocess communicate which calls communicate_with_poll
66+
if cfg.CONF.auth.sso and cfg.CONF.auth.sso_backend == 'saml2':
67+
use_select_poll_workaround(nose_only=False)
68+
6469
# Additional pre-run time checks
6570
validate_auth_backend_is_correctly_configured()
6671

st2auth/st2auth/config.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
from st2common.constants.system import DEFAULT_CONFIG_FILE_PATH
2323
from st2common.constants.auth import DEFAULT_MODE
2424
from st2common.constants.auth import DEFAULT_BACKEND
25+
from st2common.constants.auth import DEFAULT_SSO_BACKEND
2526
from st2common.constants.auth import VALID_MODES
26-
from st2auth.backends import get_available_backends
27+
from st2auth import backends as auth_backends
2728

2829

2930
def parse_args(args=None):
@@ -45,7 +46,8 @@ def _register_common_opts():
4546

4647

4748
def _register_app_opts():
48-
available_backends = get_available_backends()
49+
available_backends = auth_backends.get_available_backends()
50+
4951
auth_opts = [
5052
cfg.StrOpt(
5153
'host', default='127.0.0.1',
@@ -78,7 +80,17 @@ def _register_app_opts():
7880
cfg.StrOpt(
7981
'backend_kwargs', default=None,
8082
help='JSON serialized arguments which are passed to the authentication '
81-
'backend in a standalone mode.')
83+
'backend in a standalone mode.'),
84+
cfg.BoolOpt(
85+
'sso', default=False,
86+
help='Enable Single Sign On for GUI if true.'),
87+
cfg.StrOpt(
88+
'sso_backend', default=DEFAULT_SSO_BACKEND,
89+
help='Single Sign On backend to use when SSO is enabled. Available '
90+
'backends: noop, saml2.'),
91+
cfg.StrOpt(
92+
'sso_backend_kwargs', default=None,
93+
help='JSON serialized arguments which are passed to the SSO backend.')
8294
]
8395

8496
cfg.CONF.register_cli_opts(auth_opts, group='auth')

st2auth/st2auth/controllers/v1/root.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
# limitations under the License.
1515

1616
from st2auth.controllers.v1 import auth
17+
from st2auth.controllers.v1 import sso as sso_auth
1718

1819

1920
class RootController(object):
2021
tokens = auth.TokenController()
22+
sso = sso_auth.SingleSignOnController()

st2auth/st2auth/controllers/v1/sso.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Copyright 2019 Extreme Networks, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import datetime
16+
import json
17+
18+
from oslo_config import cfg
19+
from six.moves import http_client
20+
from six.moves import urllib
21+
22+
import st2auth.handlers as handlers
23+
24+
from st2auth import sso as st2auth_sso
25+
from st2common.exceptions import auth as auth_exc
26+
from st2common import log as logging
27+
from st2common import router
28+
29+
30+
LOG = logging.getLogger(__name__)
31+
SSO_BACKEND = st2auth_sso.get_sso_backend()
32+
33+
34+
class IdentityProviderCallbackController(object):
35+
36+
def __init__(self):
37+
self.st2_auth_handler = handlers.ProxyAuthHandler()
38+
39+
def post(self, response, **kwargs):
40+
try:
41+
verified_user = SSO_BACKEND.verify_response(response)
42+
43+
st2_auth_token_create_request = {'user': verified_user['username'], 'ttl': None}
44+
45+
st2_auth_token = self.st2_auth_handler.handle_auth(
46+
request=st2_auth_token_create_request,
47+
remote_addr=verified_user['referer'],
48+
remote_user=verified_user['username'],
49+
headers={}
50+
)
51+
52+
return process_successful_authn_response(verified_user['referer'], st2_auth_token)
53+
except NotImplementedError as e:
54+
return process_failure_response(http_client.INTERNAL_SERVER_ERROR, e)
55+
except auth_exc.SSOVerificationError as e:
56+
return process_failure_response(http_client.UNAUTHORIZED, e)
57+
except Exception as e:
58+
raise e
59+
60+
61+
class SingleSignOnRequestController(object):
62+
63+
def get(self, referer):
64+
try:
65+
response = router.Response(status=http_client.TEMPORARY_REDIRECT)
66+
response.location = SSO_BACKEND.get_request_redirect_url(referer)
67+
return response
68+
except NotImplementedError as e:
69+
return process_failure_response(http_client.INTERNAL_SERVER_ERROR, e)
70+
except Exception as e:
71+
raise e
72+
73+
74+
class SingleSignOnController(object):
75+
request = SingleSignOnRequestController()
76+
callback = IdentityProviderCallbackController()
77+
78+
def _get_sso_enabled_config(self):
79+
return {'enabled': cfg.CONF.auth.sso}
80+
81+
def get(self):
82+
try:
83+
result = self._get_sso_enabled_config()
84+
return process_successful_response(http_client.OK, result)
85+
except Exception:
86+
LOG.exception('Error encountered while getting SSO configuration.')
87+
result = {'enabled': False}
88+
return process_successful_response(http_client.OK, result)
89+
90+
91+
CALLBACK_SUCCESS_RESPONSE_BODY = """
92+
<html>
93+
<script>
94+
function getCookie(name) {
95+
var v = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)');
96+
return v ? v[2] : null;
97+
}
98+
99+
data = JSON.parse(window.localStorage.getItem('st2Session'));
100+
data['token'] = JSON.parse(decodeURIComponent(getCookie('st2-auth-token')));
101+
window.localStorage.setItem('st2Session', JSON.stringify(data));
102+
window.location.replace("%s");
103+
</script>
104+
</html>
105+
"""
106+
107+
108+
def process_successful_authn_response(referer, token):
109+
token_json = {
110+
'id': str(token.id),
111+
'user': token.user,
112+
'token': token.token,
113+
'expiry': str(token.expiry),
114+
'service': False,
115+
'metadata': {}
116+
}
117+
118+
body = CALLBACK_SUCCESS_RESPONSE_BODY % referer
119+
resp = router.Response(body=body)
120+
resp.headers['Content-Type'] = 'text/html'
121+
122+
resp.set_cookie(
123+
'st2-auth-token',
124+
value=urllib.parse.quote(json.dumps(token_json)),
125+
expires=datetime.timedelta(seconds=60),
126+
overwrite=True
127+
)
128+
129+
return resp
130+
131+
132+
def process_successful_response(status_code, json_body):
133+
return router.Response(status_code=status_code, json_body=json_body)
134+
135+
136+
def process_failure_response(status_code, exception):
137+
LOG.error(str(exception))
138+
json_body = {'faultstring': str(exception)}
139+
return router.Response(status_code=status_code, json_body=json_body)
140+
141+
142+
sso_controller = SingleSignOnController()
143+
sso_request_controller = SingleSignOnRequestController()
144+
idp_callback_controller = IdentityProviderCallbackController()

st2auth/st2auth/sso/__init__.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Copyright 2019 Extreme Networks, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import absolute_import
16+
17+
import json
18+
import six
19+
import traceback
20+
21+
from oslo_config import cfg
22+
23+
from st2common import log as logging
24+
25+
from st2common.util import driver_loader
26+
27+
28+
__all__ = [
29+
'get_available_backends',
30+
'get_backend_instance',
31+
'get_sso_backend'
32+
]
33+
34+
LOG = logging.getLogger(__name__)
35+
36+
BACKENDS_NAMESPACE = 'st2auth.sso.backends'
37+
38+
39+
def get_available_backends():
40+
return driver_loader.get_available_backends(namespace=BACKENDS_NAMESPACE)
41+
42+
43+
def get_backend_instance(name):
44+
sso_backend_cls = driver_loader.get_backend_driver(namespace=BACKENDS_NAMESPACE, name=name)
45+
46+
kwargs = {}
47+
sso_backend_kwargs = cfg.CONF.auth.sso_backend_kwargs
48+
49+
if sso_backend_kwargs:
50+
try:
51+
kwargs = json.loads(sso_backend_kwargs)
52+
except ValueError as e:
53+
raise ValueError(
54+
'Failed to JSON parse backend settings for backend "%s": %s' %
55+
(name, six.text_type(e))
56+
)
57+
58+
try:
59+
sso_backend = sso_backend_cls(**kwargs)
60+
except Exception as e:
61+
tb_msg = traceback.format_exc()
62+
class_name = sso_backend_cls.__name__
63+
msg = ('Failed to instantiate SSO backend "%s" (class %s) with backend settings '
64+
'"%s": %s' % (name, class_name, str(kwargs), six.text_type(e)))
65+
msg += '\n\n' + tb_msg
66+
exc_cls = type(e)
67+
raise exc_cls(msg)
68+
69+
return sso_backend
70+
71+
72+
def get_sso_backend():
73+
"""
74+
Return SingleSignOnBackend class instance.
75+
"""
76+
return get_backend_instance(cfg.CONF.auth.sso_backend)

st2auth/st2auth/sso/base.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Copyright 2019 Extreme Networks, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import abc
16+
import six
17+
18+
19+
__all__ = [
20+
'BaseSingleSignOnBackend'
21+
]
22+
23+
24+
@six.add_metaclass(abc.ABCMeta)
25+
class BaseSingleSignOnBackend(object):
26+
"""
27+
Base single sign on authentication class.
28+
"""
29+
30+
def get_request_redirect_url(self, referer):
31+
msg = 'The function "get_request_redirect_url" is not implemented in the base SSO backend.'
32+
raise NotImplementedError(msg)
33+
34+
def verify_response(self, response):
35+
msg = 'The function "verify_response" is not implemented in the base SSO backend.'
36+
raise NotImplementedError(msg)

0 commit comments

Comments
 (0)