Skip to content

Commit 91a257a

Browse files
committed
#654 added basic support for keycloak OpenID auth
1 parent 1428e5a commit 91a257a

File tree

8 files changed

+144
-26
lines changed

8 files changed

+144
-26
lines changed

src/auth/auth_abstract_oauth.py

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,20 @@
66
import threading
77
import time
88
import urllib.parse as urllib_parse
9-
from collections import namedtuple, defaultdict
9+
from collections import defaultdict
1010
from typing import Dict
1111

1212
import tornado
1313
import tornado.ioloop
1414
from tornado import httpclient, escape
1515

1616
from auth import auth_base
17-
from auth.auth_base import AuthFailureError, AuthBadRequestException
17+
from auth.auth_base import AuthFailureError, AuthBadRequestException, AuthRejectedError
1818
from model import model_helper
1919
from model.model_helper import read_bool_from_config, read_int_from_config
2020
from model.server_conf import InvalidServerConfigException
2121
from utils import file_utils
22+
from utils.tornado_utils import get_secure_cookie
2223

2324
LOGGER = logging.getLogger('script_server.AbstractOauthAuthenticator')
2425

@@ -31,7 +32,21 @@ def __init__(self, username) -> None:
3132
self.last_visit = None
3233

3334

34-
_OauthUserInfo = namedtuple('_OauthUserInfo', ['email', 'enabled', 'oauth_response'])
35+
class _OauthUserInfo:
36+
def __init__(self, username, enabled, oauth_response, eager_groups=None):
37+
self.username = username
38+
self.enabled = enabled
39+
self.oauth_response = oauth_response
40+
self.eager_groups = eager_groups
41+
42+
def __eq__(self, o: object) -> bool:
43+
return isinstance(o, _OauthUserInfo) and (self.username == o.username)
44+
45+
def __str__(self) -> str:
46+
return f'_OauthUserInfo({self.username})'
47+
48+
def __repr__(self) -> str:
49+
return f'_OauthUserInfo({self.__dict__})'
3550

3651

3752
def _start_timer(callback):
@@ -67,6 +82,8 @@ def __init__(self, oauth_authorize_url, oauth_token_url, oauth_scope, params_dic
6782
self._users = {} # type: Dict[str, _UserState]
6883
self._user_locks = defaultdict(lambda: asyncio.locks.Lock())
6984

85+
self.http_client = httpclient.AsyncHTTPClient()
86+
7087
self.timer = None
7188
if self.dump_file:
7289
self._restore_state()
@@ -88,28 +105,26 @@ async def authenticate(self, request_handler):
88105
LOGGER.error('Code is not specified')
89106
raise AuthBadRequestException('Missing authorization information. Please contact your administrator')
90107

91-
access_token = await self.fetch_access_token(code, request_handler)
108+
(access_token, refresh_token) = await self.fetch_access_token(code, request_handler)
92109
user_info = await self.fetch_user_info(access_token)
93110

94-
user_email = user_info.email
95-
if not user_email:
111+
username = user_info.username
112+
if not username:
96113
error_message = 'No email field in user response. The response: ' + str(user_info.oauth_response)
97114
LOGGER.error(error_message)
98115
raise AuthFailureError(error_message)
99116

100117
if not user_info.enabled:
101118
error_message = 'User %s is not enabled in OAuth provider. The response: %s' \
102-
% (user_email, str(user_info.oauth_response))
119+
% (username, str(user_info.oauth_response))
103120
LOGGER.error(error_message)
104121
raise AuthFailureError(error_message)
105122

106-
user_state = _UserState(user_email)
107-
self._users[user_email] = user_state
123+
user_state = _UserState(username)
124+
self._users[username] = user_state
108125

109126
if self.group_support:
110-
user_groups = await self.fetch_user_groups(access_token)
111-
LOGGER.info('Loaded groups for ' + user_email + ': ' + str(user_groups))
112-
user_state.groups = user_groups
127+
await self.load_groups(access_token, username, user_info, user_state)
113128

114129
now = time.time()
115130

@@ -119,7 +134,15 @@ async def authenticate(self, request_handler):
119134

120135
user_state.last_visit = now
121136

122-
return user_email
137+
return username
138+
139+
async def load_groups(self, access_token, username, user_info, user_state):
140+
if user_info.eager_groups is not None:
141+
user_state.groups = user_info.eager_groups
142+
else:
143+
user_groups = await self.fetch_user_groups(access_token)
144+
user_state.groups = user_groups
145+
LOGGER.info('Loaded groups for ' + username + ': ' + str(user_state.groups))
123146

124147
def validate_user(self, user, request_handler):
125148
if not user:
@@ -146,7 +169,7 @@ def validate_user(self, user, request_handler):
146169
user_state.last_visit = now
147170

148171
if self.auth_info_ttl:
149-
access_token = request_handler.get_secure_cookie('token')
172+
access_token = get_secure_cookie(request_handler, 'token')
150173
if access_token is None:
151174
LOGGER.info('User %s token is not available', user)
152175
return False
@@ -180,8 +203,8 @@ async def fetch_access_token(self, code, request_handler):
180203
'client_secret': self.secret,
181204
'grant_type': 'authorization_code',
182205
})
183-
http_client = httpclient.AsyncHTTPClient()
184-
response = await http_client.fetch(
206+
207+
response = await self.http_client.fetch(
185208
self.oauth_token_url,
186209
method='POST',
187210
headers={'Content-Type': 'application/x-www-form-urlencoded'},
@@ -206,13 +229,14 @@ async def fetch_access_token(self, code, request_handler):
206229

207230
response_values = escape.json_decode(response.body)
208231
access_token = response_values.get('access_token')
232+
refresh_token = response_values.get('refresh_token')
209233

210234
if not access_token:
211235
message = 'No access token in response: ' + str(response.body)
212236
LOGGER.error(message)
213237
raise AuthFailureError(message)
214238

215-
return access_token
239+
return access_token, refresh_token
216240

217241
def update_user_auth(self, username, user_state, access_token):
218242
now = time.time()
@@ -242,8 +266,14 @@ async def _do_update_user_auth_async(self, username, user_state, access_token):
242266

243267
LOGGER.info('User %s state expired, refreshing', username)
244268

245-
user_info = await self.fetch_user_info(access_token) # type: _OauthUserInfo
246-
if (not user_info) or (not user_info.email):
269+
try:
270+
user_info = await self.fetch_user_info(access_token) # type: _OauthUserInfo
271+
except AuthRejectedError:
272+
LOGGER.info(f'User {username} is not authenticated anymore. Logging out')
273+
self._remove_user(username)
274+
return
275+
276+
if (not user_info) or (not user_info.username):
247277
LOGGER.error('Failed to fetch user info: %s', str(user_info))
248278
self._remove_user(username)
249279
return
@@ -256,9 +286,7 @@ async def _do_update_user_auth_async(self, username, user_state, access_token):
256286

257287
if self.group_support:
258288
try:
259-
user_groups = await self.fetch_user_groups(access_token)
260-
LOGGER.info('Updated groups for ' + username + ': ' + str(user_groups))
261-
user_state.groups = user_groups
289+
await self.load_groups(access_token, username, user_info, user_state)
262290
except AuthFailureError:
263291
LOGGER.error('Failed to fetch user %s groups', username)
264292
self._remove_user(username)

src/auth/auth_keycloak_openid.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import logging
2+
3+
from tornado import escape
4+
from tornado.httpclient import HTTPClientError
5+
6+
from auth.auth_abstract_oauth import AbstractOauthAuthenticator, _OauthUserInfo
7+
from auth.auth_base import AuthRejectedError
8+
from model import model_helper
9+
10+
LOGGER = logging.getLogger('script_server.GoogleOauthAuthorizer')
11+
12+
13+
# noinspection PyProtectedMember
14+
class KeycloakOpenidAuthenticator(AbstractOauthAuthenticator):
15+
def __init__(self, params_dict):
16+
realm_url = model_helper.read_obligatory(
17+
params_dict,
18+
'realm_url',
19+
': should contain openid realm url, e.g. http://localhost:8080/realms/master')
20+
if not realm_url.endswith('/'):
21+
realm_url = realm_url + '/'
22+
self._realm_url = realm_url
23+
24+
super().__init__(realm_url + 'protocol/openid-connect/auth',
25+
realm_url + 'protocol/openid-connect/token',
26+
# "openid" scope is needed since version 20:
27+
# https://keycloak.discourse.group/t/issue-on-userinfo-endpoint-at-keycloak-20/18461/2
28+
'email openid',
29+
params_dict)
30+
31+
async def fetch_user_info(self, access_token) -> _OauthUserInfo:
32+
user_future = self.http_client.fetch(
33+
self._realm_url + 'protocol/openid-connect/userinfo',
34+
headers={'Authorization': 'Bearer ' + access_token})
35+
36+
try:
37+
user_response = await user_future
38+
except HTTPClientError as e:
39+
if e.code == 401:
40+
raise AuthRejectedError('Failed to fetch user info')
41+
else:
42+
raise e
43+
44+
if not user_response:
45+
raise Exception('No response during loading userinfo')
46+
47+
response_values = {}
48+
if user_response.body:
49+
response_values = escape.json_decode(user_response.body)
50+
51+
eager_groups = None
52+
if self.group_support:
53+
eager_groups = response_values.get('groups')
54+
if eager_groups is None:
55+
eager_groups = []
56+
LOGGER.warning('Failed to load user groups. Most probably groups mapping is not enabled. '
57+
'Check the corresponding wiki section')
58+
59+
return _OauthUserInfo(response_values.get('preferred_username'), True, response_values, eager_groups)
60+
61+
async def fetch_user_groups(self, access_token):
62+
raise Exception('This shouldn\'t be used, all the groups should be fetched with user info.')

src/model/server_conf.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,9 @@ def create_authenticator(auth_object, temp_folder, process_invoker: ProcessInvok
215215
elif auth_type == 'gitlab':
216216
from auth.auth_gitlab import GitlabOAuthAuthenticator
217217
authenticator = GitlabOAuthAuthenticator(auth_object)
218+
elif auth_type == 'keycloak_openid':
219+
from auth.auth_keycloak_openid import KeycloakOpenidAuthenticator
220+
authenticator = KeycloakOpenidAuthenticator(auth_object)
218221
elif auth_type == 'htpasswd':
219222
from auth.auth_htpasswd import HtpasswdAuthenticator
220223
authenticator = HtpasswdAuthenticator(auth_object, process_invoker)

src/tests/auth/test_auth_abstract_oauth.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from tests import test_utils
1717
from tests.test_utils import mock_object
1818
from utils import file_utils
19+
from utils.tornado_utils import get_secure_cookie
1920

2021
mock_time = Mock()
2122
mock_time.return_value = 10000.01
@@ -156,7 +157,7 @@ def mock_request_handler(code):
156157
handler_mock.get_secure_cookie = lambda cookie: secure_cookies.get(cookie)
157158

158159
def set_secure_cookie(cookie, value):
159-
secure_cookies[cookie] = value
160+
secure_cookies[cookie] = value.encode('utf-8')
160161

161162
def clear_secure_cookie(cookie):
162163
if cookie in secure_cookies:
@@ -240,7 +241,7 @@ def test_authenticate_and_save_user_token(self):
240241
request_handler = mock_request_handler(code='Y')
241242
yield authenticator.authenticate(request_handler)
242243

243-
saved_token = request_handler.get_secure_cookie('token')
244+
saved_token = get_secure_cookie(request_handler, 'token')
244245
self.assertEqual('22222', saved_token)
245246

246247
@gen_test
@@ -622,7 +623,7 @@ def __init__(self, params_dict):
622623
async def fetch_access_token(self, code, request_handler):
623624
for key, value in self.user_tokens.items():
624625
if value.endswith(code):
625-
return key
626+
return key, None
626627

627628
raise Exception('Could not generate token for code ' + code + '. Make sure core is equal to user suffix')
628629

web-src/public/login.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,11 @@
5252
</div>
5353
</script>
5454

55+
<script id="login-keycloak-template" type="text/template">
56+
<div>
57+
<input type="submit" id="login-keycloak-button" class="login-button oauth-button" value="Sign in with Keycloak">
58+
<div class="login-info-label"></div>
59+
</div>
60+
</script>
61+
5562
</html>

web-src/src/assets/css/index.css

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ h6.header {
100100
color: #F44336;
101101
}
102102

103-
#login-panel .login-google_oauth .login-info-label {
103+
#login-panel .login-google_oauth .login-info-label,
104+
#login-panel .login-gitlab .login-info-label,
105+
#login-panel .login-keycloak .login-info-label {
104106
margin-top: 16px;
105107
}
106108

@@ -154,3 +156,8 @@ h6.header {
154156
background-image: url('../gitlab-icon-rgb.png');
155157
background-position-x: 6px;
156158
}
159+
160+
#login-keycloak-button {
161+
padding-left: 42px;
162+
background-image: url('../keycloak_icon.png');
163+
}
3.13 KB
Loading

web-src/src/login/login.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ function onLoad() {
4040

4141
if (config['type'] === 'google_oauth') {
4242
setupGoogleOAuth(loginContainer, config);
43+
} else if (config['type'] === 'keycloak_openid') {
44+
setupKeycloakOpenid(loginContainer, config);
4345
} else if (config['type'] === 'gitlab') {
4446
setupGitlabOAuth(loginContainer, config);
4547
} else {
@@ -79,6 +81,14 @@ function setupGoogleOAuth(loginContainer, authConfig) {
7981
'login-google_oauth-button')
8082
}
8183

84+
function setupKeycloakOpenid(loginContainer, authConfig) {
85+
setupOAuth(
86+
loginContainer,
87+
authConfig,
88+
'login-keycloak-template',
89+
'login-keycloak-button')
90+
}
91+
8292
function setupGitlabOAuth(loginContainer, authConfig) {
8393
setupOAuth(
8494
loginContainer,

0 commit comments

Comments
 (0)