diff --git a/src/auth/auth_abstract_oauth.py b/src/auth/auth_abstract_oauth.py new file mode 100644 index 00000000..81210085 --- /dev/null +++ b/src/auth/auth_abstract_oauth.py @@ -0,0 +1,328 @@ +import abc +import json +import logging +import os +import threading +import time +import urllib.parse as urllib_parse +from collections import namedtuple, defaultdict +from typing import Dict + +import tornado +import tornado.ioloop +from tornado import httpclient, escape + +from auth import auth_base +from auth.auth_base import AuthFailureError, AuthBadRequestException +from model import model_helper +from model.model_helper import read_bool_from_config, read_int_from_config +from model.server_conf import InvalidServerConfigException +from utils import file_utils + +LOGGER = logging.getLogger('script_server.AbstractOauthAuthenticator') + + +class _UserState: + def __init__(self, username) -> None: + self.username = username + self.groups = [] + self.last_auth_update = None + self.last_visit = None + + +_OauthUserInfo = namedtuple('_OauthUserInfo', ['email', 'enabled', 'oauth_response']) + + +def _start_timer(callback): + timer = threading.Timer(30, callback) + timer.setDaemon(True) + timer.start() + return timer + + +class AbstractOauthAuthenticator(auth_base.Authenticator, metaclass=abc.ABCMeta): + def __init__(self, oauth_authorize_url, oauth_token_url, oauth_scope, params_dict): + super().__init__() + + self.oauth_token_url = oauth_token_url + self.oauth_scope = oauth_scope + + self.client_id = model_helper.read_obligatory(params_dict, 'client_id', ' for OAuth') + secret_value = model_helper.read_obligatory(params_dict, 'secret', ' for OAuth') + self.secret = model_helper.resolve_env_vars(secret_value, full_match=True) + + self._client_visible_config['client_id'] = self.client_id + self._client_visible_config['oauth_url'] = oauth_authorize_url + self._client_visible_config['oauth_scope'] = oauth_scope + + self.group_support = read_bool_from_config('group_support', params_dict, default=True) + self.auth_info_ttl = params_dict.get('auth_info_ttl') + self.session_expire = read_int_from_config('session_expire_minutes', params_dict, default=0) * 60 + self.dump_file = params_dict.get('state_dump_file') + + if self.dump_file: + self._validate_dump_file(self.dump_file) + + self._users = {} # type: Dict[str, _UserState] + self._user_locks = defaultdict(lambda: threading.Lock()) + + self.timer = None + if self.dump_file: + self._restore_state() + + self._schedule_dump_task() + + @staticmethod + def _validate_dump_file(dump_file): + if os.path.isdir(dump_file): + raise InvalidServerConfigException('Please specify dump FILE instead of folder for OAuth') + dump_folder = os.path.abspath(os.path.dirname(dump_file)) + if not os.path.exists(dump_folder): + raise InvalidServerConfigException('OAuth dump file folder does not exist: ' + dump_folder) + + async def authenticate(self, request_handler): + code = request_handler.get_argument('code', False) + + if not code: + LOGGER.error('Code is not specified') + raise AuthBadRequestException('Missing authorization information. Please contact your administrator') + + access_token = await self.fetch_access_token(code, request_handler) + user_info = await self.fetch_user_info(access_token) + + user_email = user_info.email + if not user_email: + error_message = 'No email field in user response. The response: ' + str(user_info.oauth_response) + LOGGER.error(error_message) + raise AuthFailureError(error_message) + + if not user_info.enabled: + error_message = 'User %s is not enabled in OAuth provider. The response: %s' \ + % (user_email, str(user_info.oauth_response)) + LOGGER.error(error_message) + raise AuthFailureError(error_message) + + user_state = _UserState(user_email) + self._users[user_email] = user_state + + if self.group_support: + user_groups = await self.fetch_user_groups(access_token) + LOGGER.info('Loaded groups for ' + user_email + ': ' + str(user_groups)) + user_state.groups = user_groups + + now = time.time() + + if self.auth_info_ttl: + request_handler.set_secure_cookie('token', access_token) + user_state.last_auth_update = now + + user_state.last_visit = now + + return user_email + + def validate_user(self, user, request_handler): + if not user: + LOGGER.warning('Username is not available') + return False + + now = time.time() + + user_state = self._users.get(user) + if not user_state: + # if nothing is enabled, it's ok not to have user state (e.g. after server restart) + if self.session_expire <= 0 and not self.auth_info_ttl and not self.group_support: + return True + else: + LOGGER.info('User %s state is missing', user) + return False + + if self.session_expire > 0: + last_visit = user_state.last_visit + if (last_visit is None) or ((last_visit + self.session_expire) < now): + LOGGER.info('User %s state is expired', user) + return False + + user_state.last_visit = now + + if self.auth_info_ttl: + access_token = request_handler.get_secure_cookie('token') + if access_token is None: + LOGGER.info('User %s token is not available', user) + return False + + self.update_user_auth(user, user_state, access_token) + + return True + + def get_groups(self, user, known_groups=None): + user_state = self._users.get(user) + if not user_state: + return [] + + return user_state.groups + + def logout(self, user, request_handler): + request_handler.clear_cookie('token') + self._remove_user(user) + + self._dump_state() + + def _remove_user(self, user): + if user in self._users: + del self._users[user] + + async def fetch_access_token(self, code, request_handler): + body = urllib_parse.urlencode({ + 'redirect_uri': get_path_for_redirect(request_handler), + 'code': code, + 'client_id': self.client_id, + 'client_secret': self.secret, + 'grant_type': 'authorization_code', + }) + http_client = httpclient.AsyncHTTPClient() + response = await http_client.fetch( + self.oauth_token_url, + method='POST', + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + body=body, + raise_error=False) + + response_values = {} + if response.body: + response_values = escape.json_decode(response.body) + + if response.error: + if response_values.get('error_description'): + error_text = response_values.get('error_description') + elif response_values.get('error'): + error_text = response_values.get('error') + else: + error_text = str(response.error) + + error_message = 'Failed to load access_token: ' + error_text + LOGGER.error(error_message) + raise AuthFailureError(error_message) + + response_values = escape.json_decode(response.body) + access_token = response_values.get('access_token') + + if not access_token: + message = 'No access token in response: ' + str(response.body) + LOGGER.error(message) + raise AuthFailureError(message) + + return access_token + + def update_user_auth(self, username, user_state, access_token): + now = time.time() + ttl_expired = (user_state.last_auth_update is None) \ + or ((user_state.last_auth_update + self.auth_info_ttl) < now) + + if not ttl_expired: + return + + tornado.ioloop.IOLoop.current().add_callback( + self._do_update_user_auth_async, + username, + user_state, + access_token) + + async def _do_update_user_auth_async(self, username, user_state, access_token): + lock = self._user_locks[username] + + with lock: + now = time.time() + + ttl_expired = (user_state.last_auth_update is None) \ + or ((user_state.last_auth_update + self.auth_info_ttl) < now) + + if not ttl_expired: + return + + LOGGER.info('User %s state expired, refreshing', username) + + user_info = await self.fetch_user_info(access_token) # type: _OauthUserInfo + if (not user_info) or (not user_info.email): + LOGGER.error('Failed to fetch user info: %s', str(user_info)) + self._remove_user(username) + return + + if not user_info.enabled: + LOGGER.error('User %s, was deactivated on OAuth server. New state: %s', username, + str(user_info.oauth_response)) + self._remove_user(username) + return + + if self.group_support: + try: + user_groups = await self.fetch_user_groups(access_token) + LOGGER.info('Updated groups for ' + username + ': ' + str(user_groups)) + user_state.groups = user_groups + except AuthFailureError: + LOGGER.error('Failed to fetch user %s groups', username) + self._remove_user(username) + return + + user_state.last_auth_update = now + + def _restore_state(self): + if not os.path.exists(self.dump_file): + LOGGER.info('OAuth dump file is missing. Nothing to restore') + return + + dump_data = file_utils.read_file(self.dump_file) + dump_json = json.loads(dump_data) + + for user_state in dump_json: + username = user_state.get('username') + if not username: + LOGGER.warning('Missing username in ' + str(user_state)) + continue + + state = _UserState(username) + self._users[username] = state + state.groups = user_state.get('groups', []) + state.last_auth_update = user_state.get('last_auth_update') + state.last_visit = user_state.get('last_visit') + + def _schedule_dump_task(self): + def repeating_dump(): + try: + self._dump_state() + finally: + self._schedule_dump_task() + + self.timer = _start_timer(repeating_dump) + + def _dump_state(self): + if self.dump_file: + states = [s.__dict__ for s in self._users.values()] + state_json = json.dumps(states) + file_utils.write_file(self.dump_file, state_json) + + @abc.abstractmethod + async def fetch_user_info(self, access_token: str) -> _OauthUserInfo: + pass + + @abc.abstractmethod + async def fetch_user_groups(self, access_token): + pass + + # Tests only + def _cleanup(self): + if self.timer: + self.timer.cancel() + + +def get_path_for_redirect(request_handler): + referer = request_handler.request.headers.get('Referer') + if not referer: + LOGGER.error('No referer') + raise AuthFailureError('Missing request header. Please contact system administrator') + + parse_result = urllib_parse.urlparse(referer) + protocol = parse_result[0] + host = parse_result[1] + path = parse_result[2] + + return urllib_parse.urlunparse((protocol, host, path, '', '', '')) diff --git a/src/auth/auth_base.py b/src/auth/auth_base.py index 367e3782..ff4ca985 100644 --- a/src/auth/auth_base.py +++ b/src/auth/auth_base.py @@ -17,6 +17,12 @@ def get_client_visible_config(self): def get_groups(self, user, known_groups=None): return [] + def validate_user(self, user, request_handler): + return True + + def logout(self, user, request_handler): + return None + class AuthRejectedError(Exception): """Credentials, provided by user, were rejected by the authentication mechanism (user is unknown to the server)""" diff --git a/src/auth/auth_gitlab.py b/src/auth/auth_gitlab.py new file mode 100644 index 00000000..e53be74f --- /dev/null +++ b/src/auth/auth_gitlab.py @@ -0,0 +1,64 @@ +import logging + +from tornado.auth import OAuth2Mixin + +from auth.auth_abstract_oauth import AbstractOauthAuthenticator, _OauthUserInfo + +LOGGER = logging.getLogger('script_server.GitlabAuthorizer') + +_OAUTH_AUTHORIZE_URL = '%s/oauth/authorize' +_OAUTH_ACCESS_TOKEN_URL = '%s/oauth/token' +_OAUTH_GITLAB_USERINFO = '%s/api/v4/user' +_OAUTH_GITLAB_GROUPS = '%s/api/v4/groups' + + +# noinspection PyProtectedMember +class GitlabOAuthAuthenticator(AbstractOauthAuthenticator, OAuth2Mixin): + def __init__(self, params_dict): + self.gitlab_host = params_dict.get('url', 'https://gitlab.com') + gitlab_group_support = params_dict.get('group_support', True) + + super().__init__( + _OAUTH_AUTHORIZE_URL % self.gitlab_host, + _OAUTH_ACCESS_TOKEN_URL % self.gitlab_host, + 'api' if gitlab_group_support else 'read_user', + params_dict) + + self.gitlab_group_search = params_dict.get('group_search') + + async def fetch_user_info(self, access_token) -> _OauthUserInfo: + user = await self.oauth2_request( + _OAUTH_GITLAB_USERINFO % self.gitlab_host, + access_token) + if user is None: + return None + + active = user.get('state') == 'active' + return _OauthUserInfo(user.get('email'), active, user) + + async def fetch_user_groups(self, access_token): + args = { + 'access_token': access_token, + 'all_available': 'false', + 'per_page': 100, + } + + if self.gitlab_group_search is not None: + args['search'] = self.gitlab_group_search + + group_list_future = self.oauth2_request( + _OAUTH_GITLAB_GROUPS % self.gitlab_host, + **args + ) + + group_list = await group_list_future + + if group_list is None: + return None + + groups = [] + for group in group_list: + if group.get('full_path'): + groups.append(group['full_path']) + + return groups diff --git a/src/auth/auth_google_oauth.py b/src/auth/auth_google_oauth.py index 5e038b00..9fded43d 100644 --- a/src/auth/auth_google_oauth.py +++ b/src/auth/auth_google_oauth.py @@ -1,111 +1,32 @@ import logging -import urllib.parse as urllib_parse import tornado.auth -from tornado import gen, httpclient, escape -from auth import auth_base -from auth.auth_base import AuthFailureError, AuthBadRequestException -from model import model_helper +from auth.auth_abstract_oauth import AbstractOauthAuthenticator, _OauthUserInfo LOGGER = logging.getLogger('script_server.GoogleOauthAuthorizer') # noinspection PyProtectedMember -class GoogleOauthAuthenticator(auth_base.Authenticator): +class GoogleOauthAuthenticator(AbstractOauthAuthenticator): def __init__(self, params_dict): - super().__init__() + params_dict['group_support'] = False - self.client_id = model_helper.read_obligatory(params_dict, 'client_id', ' for Google OAuth') - - secret_value = model_helper.read_obligatory(params_dict, 'secret', ' for Google OAuth') - self.secret = model_helper.resolve_env_vars(secret_value, full_match=True) - - self.states = {} - - self._client_visible_config['client_id'] = self.client_id - self._client_visible_config['oauth_url'] = tornado.auth.GoogleOAuth2Mixin._OAUTH_AUTHORIZE_URL - self._client_visible_config['oauth_scope'] = 'email' - - def authenticate(self, request_handler): - code = request_handler.get_argument('code', False) - - if not code: - LOGGER.error('Code is not specified') - raise AuthBadRequestException('Missing authorization information. Please contact your administrator') - - return self.read_user(code, request_handler) - - @gen.coroutine - def read_user(self, code, request_handler): - access_token = yield self.get_access_token(code, request_handler) + super().__init__(tornado.auth.GoogleOAuth2Mixin._OAUTH_AUTHORIZE_URL, + tornado.auth.GoogleOAuth2Mixin._OAUTH_ACCESS_TOKEN_URL, + 'email', + params_dict) + async def fetch_user_info(self, access_token) -> _OauthUserInfo: oauth_mixin = tornado.auth.GoogleOAuth2Mixin() user_future = oauth_mixin.oauth2_request( tornado.auth.GoogleOAuth2Mixin._OAUTH_USERINFO_URL, access_token=access_token) - user_response = yield user_future - - if user_response.get('email'): - return user_response.get('email') - - error_message = 'No email field in user response. The response: ' + str(user_response) - LOGGER.error(error_message) - raise AuthFailureError(error_message) - - @gen.coroutine - def get_access_token(self, code, request_handler): - body = urllib_parse.urlencode({ - 'redirect_uri': get_path_for_redirect(request_handler), - 'code': code, - 'client_id': self.client_id, - 'client_secret': self.secret, - 'grant_type': 'authorization_code', - }) - http_client = httpclient.AsyncHTTPClient() - response = yield http_client.fetch( - tornado.auth.GoogleOAuth2Mixin._OAUTH_ACCESS_TOKEN_URL, - method='POST', - headers={'Content-Type': 'application/x-www-form-urlencoded'}, - body=body, - raise_error=False) - - response_values = {} - if response.body: - response_values = escape.json_decode(response.body) - - if response.error: - if response_values.get('error_description'): - error_text = response_values.get('error_description') - elif response_values.get('error'): - error_text = response_values.get('error') - else: - error_text = str(response.error) - - error_message = 'Failed to load access_token: ' + error_text - LOGGER.error(error_message) - raise AuthFailureError(error_message) - - response_values = escape.json_decode(response.body) - access_token = response_values.get('access_token') - - if not access_token: - message = 'No access token in response: ' + str(response.body) - LOGGER.error(message) - raise AuthFailureError(message) - - return access_token - - -def get_path_for_redirect(request_handler): - referer = request_handler.request.headers.get('Referer') - if not referer: - LOGGER.error('No referer') - raise AuthFailureError('Missing request header. Please contact system administrator') + user_response = await user_future + if not user_response: + return None - parse_result = urllib_parse.urlparse(referer) - protocol = parse_result[0] - host = parse_result[1] - path = parse_result[2] + return _OauthUserInfo(user_response.get('email'), True, user_response) - return urllib_parse.urlunparse((protocol, host, path, '', '', '')) + async def fetch_user_groups(self, access_token): + return [] diff --git a/src/auth/tornado_auth.py b/src/auth/tornado_auth.py index 149bb573..35f4847e 100644 --- a/src/auth/tornado_auth.py +++ b/src/auth/tornado_auth.py @@ -1,3 +1,4 @@ +import asyncio import logging import tornado.concurrent @@ -23,8 +24,14 @@ def is_authenticated(self, request_handler): return True username = self._get_current_user(request_handler) + if not username: + return False + + active = self.authenticator.validate_user(username, request_handler) + if not active: + self.logout(request_handler) - return bool(username) + return active @staticmethod def _get_current_user(request_handler): @@ -48,7 +55,7 @@ def authenticate(self, request_handler): try: username = self.authenticator.authenticate(request_handler) - if isinstance(username, tornado.concurrent.Future): + if asyncio.iscoroutine(username): username = yield username except auth_base.AuthRejectedError as e: @@ -98,3 +105,5 @@ def logout(self, request_handler): LOGGER.info('Logging out ' + username) request_handler.clear_cookie('username') + + self.authenticator.logout(username, request_handler) diff --git a/src/concurrency/__init__.py b/src/concurrency/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/concurrency/countdown_latch.py b/src/concurrency/countdown_latch.py new file mode 100644 index 00000000..cf6819da --- /dev/null +++ b/src/concurrency/countdown_latch.py @@ -0,0 +1,36 @@ +import threading +import time + + +class CountDownLatch(object): + def __init__(self, count=1): + self.count = count + self.lock = threading.Condition() + + def count_down(self): + with self.lock: + self.count -= 1 + if self.count <= 0: + print('count_down: count = ' + str(self.count)) + self.lock.notifyAll() + + def await_latch(self, timeout=None): + if timeout: + end_time = time.time() + timeout + + with self.lock: + while self.count > 0: + wait_delta = end_time - time.time() + + if wait_delta > 0: + print('await_latch before wait: count = ' + str(self.count)) + self.lock.wait(wait_delta) + print('await_latch after wait: count = ' + str(self.count)) + else: + raise TimeoutError('Latch await timed out') + + return + + with self.lock: + while self.count > 0: + self.lock.wait() diff --git a/src/model/server_conf.py b/src/model/server_conf.py index 2f6b1ac6..88b2b2e7 100644 --- a/src/model/server_conf.py +++ b/src/model/server_conf.py @@ -150,6 +150,9 @@ def create_authenticator(auth_object, temp_folder): elif auth_type == 'google_oauth': from auth.auth_google_oauth import GoogleOauthAuthenticator authenticator = GoogleOauthAuthenticator(auth_object) + elif auth_type == 'gitlab': + from auth.auth_gitlab import GitlabOAuthAuthenticator + authenticator = GitlabOAuthAuthenticator(auth_object) elif auth_type == 'htpasswd': from auth.auth_htpasswd import HtpasswdAuthenticator authenticator = HtpasswdAuthenticator(auth_object) diff --git a/src/tests/auth/__init__.py b/src/tests/auth/__init__.py old mode 100755 new mode 100644 diff --git a/src/tests/auth/test_auth_abstract_oauth.py b/src/tests/auth/test_auth_abstract_oauth.py new file mode 100644 index 00000000..47325314 --- /dev/null +++ b/src/tests/auth/test_auth_abstract_oauth.py @@ -0,0 +1,646 @@ +import json +import os +import random +import threading +import time +from unittest import TestCase +from unittest.mock import Mock, patch + +from tornado import gen +from tornado.testing import AsyncTestCase, gen_test + +import auth +from auth.auth_abstract_oauth import AbstractOauthAuthenticator, _OauthUserInfo +from auth.auth_base import AuthFailureError, AuthBadRequestException +from model.server_conf import InvalidServerConfigException +from tests import test_utils +from tests.test_utils import mock_object +from utils import file_utils + +mock_time = Mock() +mock_time.return_value = 10000.01 + +authenticators = [] + + +class _OauthTestCase(AsyncTestCase): + def setUp(self) -> None: + super().setUp() + test_utils.setup() + + mock_time.return_value = 10000.01 + + def tearDown(self): + super().tearDown() + test_utils.cleanup() + + for authenticator in authenticators: + authenticator._cleanup() + + +def create_test_authenticator(*, dump_file=None, group_support=None, session_expire_minutes=None, auth_info_ttl=None): + config = { + 'type': 'test_oauth', + 'url': 'some_url', + 'client_id': '1234', + 'secret': 'abcd', + 'group_search': 'script-server' + } + + if dump_file is not None: + config['state_dump_file'] = dump_file + + if group_support is not None: + config['group_support'] = group_support + + if session_expire_minutes is not None: + config['session_expire_minutes'] = session_expire_minutes + + if auth_info_ttl is not None: + config['auth_info_ttl'] = auth_info_ttl + + authenticator = MockOauthAuthenticator(config) + + authenticators.append(authenticator) + + return authenticator + + +class TestAuthConfig(TestCase): + def test_client_visible_config(self): + authenticator = create_test_authenticator() + + client_visible_config = authenticator._client_visible_config + self.assertEqual('1234', client_visible_config['client_id']) + self.assertEqual('authorize_url', client_visible_config['oauth_url']) + self.assertEqual('test_scope', client_visible_config['oauth_scope']) + + def test_config_values(self): + dump_file_path = os.path.join(test_utils.temp_folder, 'dump.json') + authenticator = create_test_authenticator(dump_file=dump_file_path, session_expire_minutes=10, auth_info_ttl=80) + + self.assertEqual('1234', authenticator.client_id) + self.assertEqual('abcd', authenticator.secret) + self.assertEqual(True, authenticator.group_support) + self.assertEqual(80, authenticator.auth_info_ttl) + self.assertEqual(600, authenticator.session_expire) + self.assertEqual(dump_file_path, authenticator.dump_file) + + def test_group_support_disabled(self): + authenticator = create_test_authenticator(group_support=False) + + self.assertEqual(False, authenticator.group_support) + + def test_no_session_expire(self): + authenticator = create_test_authenticator() + + self.assertEqual(0, authenticator.session_expire) + + def test_dump_file_when_folder(self): + self.assertRaisesRegex( + InvalidServerConfigException, + 'dump FILE instead of folder', + create_test_authenticator, + dump_file=test_utils.temp_folder) + + def test_dump_file_when_folder_not_exists(self): + self.assertRaisesRegex( + InvalidServerConfigException, + 'OAuth dump file folder does not exist', + create_test_authenticator, + dump_file=os.path.join(test_utils.temp_folder, 'sub', 'dump.json')) + + def test_restore_dump_state_when_no_file(self): + dump_file_path = os.path.join(test_utils.temp_folder, 'dump.json') + authenticator = create_test_authenticator(dump_file=dump_file_path) + + self.assertEqual({}, authenticator._users) + + def test_restore_dump_state_when_multiple_users(self): + dump_file = test_utils.create_file('dump.json', text=json.dumps( + [{'username': 'user_X', 'groups': ['group1', 'group2'], 'last_auth_update': 123}, + {'groups': ['group3'], 'last_auth_update': 999}, + {'username': 'user_Y', 'last_visit': 456}])) + authenticator = create_test_authenticator(dump_file=dump_file) + + self.assertEqual({'user_X', 'user_Y'}, authenticator._users.keys()) + + user_x_state = authenticator._users['user_X'] + self.assertEqual('user_X', user_x_state.username) + self.assertEqual(['group1', 'group2'], user_x_state.groups) + self.assertEqual(123, user_x_state.last_auth_update) + self.assertEqual(None, user_x_state.last_visit) + + user_y_state = authenticator._users['user_Y'] + self.assertEqual('user_Y', user_y_state.username) + self.assertEqual([], user_y_state.groups) + self.assertEqual(None, user_y_state.last_auth_update) + self.assertEqual(456, user_y_state.last_visit) + + def setUp(self) -> None: + test_utils.setup() + + def tearDown(self): + test_utils.cleanup() + + for authenticator in authenticators: + authenticator._cleanup() + + +def mock_request_handler(code): + handler_mock = mock_object() + handler_mock.get_argument = lambda arg, default: code if arg == 'code' else None + + secure_cookies = {} + + handler_mock.get_secure_cookie = lambda cookie: secure_cookies.get(cookie) + + def set_secure_cookie(cookie, value): + secure_cookies[cookie] = value + + def clear_secure_cookie(cookie): + if cookie in secure_cookies: + del secure_cookies[cookie] + + handler_mock.set_secure_cookie = set_secure_cookie + handler_mock.clear_cookie = clear_secure_cookie + + return handler_mock + + +class TestAuthenticate(_OauthTestCase): + @gen_test + def test_authenticate_successful(self): + authenticator = create_test_authenticator() + username = yield authenticator.authenticate(mock_request_handler(code='X')) + + self.assertEqual('user_X', username) + + @gen_test + def test_authenticate_successful_different_user(self): + authenticator = create_test_authenticator() + username = yield authenticator.authenticate(mock_request_handler(code='Z')) + + self.assertEqual('user_Z', username) + + @gen_test + def test_authenticate_when_no_code(self): + authenticator = create_test_authenticator() + with self.assertRaisesRegex(AuthBadRequestException, 'Missing authorization information'): + yield authenticator.authenticate(mock_request_handler(code=None)) + + @gen_test + def test_authenticate_when_no_token(self): + authenticator = create_test_authenticator() + with self.assertRaisesRegex(Exception, 'Could not generate token'): + yield authenticator.authenticate(mock_request_handler(code='W')) + + @gen_test + def test_authenticate_when_no_email(self): + authenticator = create_test_authenticator() + + async def custom_fetch_user_info(access_token): + return _OauthUserInfo(None, True, {}) + + authenticator.fetch_user_info = custom_fetch_user_info + + with self.assertRaisesRegex(AuthFailureError, 'No email field in user response'): + yield authenticator.authenticate(mock_request_handler(code='X')) + + @gen_test + def test_authenticate_when_not_enabled(self): + authenticator = create_test_authenticator() + authenticator.disabled_users.append('user_Y') + + with self.assertRaisesRegex(AuthFailureError, 'is not enabled in OAuth provider'): + yield authenticator.authenticate(mock_request_handler(code='Y')) + + @gen_test + def test_authenticate_and_get_user_groups(self): + authenticator = create_test_authenticator(group_support=True) + authenticator.user_groups['user_Y'] = ['group1', 'group2'] + + username = yield authenticator.authenticate(mock_request_handler(code='Y')) + groups = authenticator.get_groups(username) + self.assertEqual(['group1', 'group2'], groups) + + @gen_test + def test_authenticate_and_get_user_groups_when_groups_disabled(self): + authenticator = create_test_authenticator(group_support=False) + authenticator.user_groups['user_Y'] = ['group1', 'group2'] + + username = yield authenticator.authenticate(mock_request_handler(code='Y')) + groups = authenticator.get_groups(username) + self.assertEqual([], groups) + + @gen_test + def test_authenticate_and_save_user_token(self): + authenticator = create_test_authenticator(auth_info_ttl=10) + + request_handler = mock_request_handler(code='Y') + yield authenticator.authenticate(request_handler) + + saved_token = request_handler.get_secure_cookie('token') + self.assertEqual('22222', saved_token) + + @gen_test + def test_authenticate_and_save_user_token_when_auth_update_disabled(self): + authenticator = create_test_authenticator(auth_info_ttl=None) + + request_handler = mock_request_handler(code='Y') + yield authenticator.authenticate(request_handler) + + saved_token = request_handler.get_secure_cookie('token') + self.assertIsNone(saved_token) + + +class TestValidateUser(_OauthTestCase): + @gen_test + def test_validate_user_success(self): + authenticator = create_test_authenticator() + + request_handler = mock_request_handler('X') + username = yield authenticator.authenticate(request_handler) + + valid = authenticator.validate_user(username, request_handler) + self.assertEqual(True, valid) + + @gen_test + def test_validate_when_no_state(self): + authenticator = create_test_authenticator(group_support=False) + + valid = authenticator.validate_user('user_X', mock_request_handler('')) + self.assertEqual(True, valid) + + @gen_test + def test_validate_when_no_username(self): + authenticator = create_test_authenticator(group_support=False) + + valid = authenticator.validate_user(None, mock_request_handler('')) + self.assertEqual(False, valid) + + @gen_test + def test_validate_when_no_state_and_expire_enabled(self): + authenticator = create_test_authenticator(session_expire_minutes=1) + + valid = authenticator.validate_user('user_X', mock_request_handler('')) + self.assertEqual(False, valid) + + @gen_test + def test_validate_when_no_state_and_auth_update_enabled(self): + authenticator = create_test_authenticator(auth_info_ttl=1) + + valid = authenticator.validate_user('user_X', mock_request_handler('')) + self.assertEqual(False, valid) + + @gen_test + def test_validate_when_no_state_and_group_support(self): + authenticator = create_test_authenticator(group_support=True) + + valid = authenticator.validate_user('user_X', mock_request_handler('')) + self.assertEqual(False, valid) + + @patch('time.time', mock_time) + @gen_test + def test_validate_when_session_expired(self): + authenticator = create_test_authenticator(session_expire_minutes=5) + + request_handler = mock_request_handler('X') + username = yield authenticator.authenticate(request_handler) + + mock_time.return_value = mock_time.return_value + 60 * 10 + valid = authenticator.validate_user(username, request_handler) + self.assertEqual(False, valid) + + @patch('time.time', mock_time) + @gen_test + def test_validate_when_session_not_expired(self): + authenticator = create_test_authenticator(session_expire_minutes=5) + + request_handler = mock_request_handler('X') + username = yield authenticator.authenticate(request_handler) + + mock_time.return_value = mock_time.return_value + 60 * 2 + valid = authenticator.validate_user(username, request_handler) + self.assertEqual(True, valid) + + @patch('time.time', mock_time) + @gen_test + def test_validate_when_session_not_expired_after_renew(self): + authenticator = create_test_authenticator(session_expire_minutes=5) + + request_handler = mock_request_handler('X') + username = yield authenticator.authenticate(request_handler) + + mock_time.return_value = mock_time.return_value + 60 * 2 + authenticator.validate_user(username, request_handler) + + mock_time.return_value = mock_time.return_value + 60 * 4 + valid2 = authenticator.validate_user(username, request_handler) + self.assertEqual(True, valid2) + + @patch('time.time', mock_time) + @gen_test + def test_validate_when_session_expired_after_renew(self): + authenticator = create_test_authenticator(session_expire_minutes=5) + + request_handler = mock_request_handler('X') + username = yield authenticator.authenticate(request_handler) + + mock_time.return_value = mock_time.return_value + 60 * 2 + authenticator.validate_user(username, request_handler) + + mock_time.return_value = mock_time.return_value + 60 * 6 + valid2 = authenticator.validate_user(username, request_handler) + self.assertEqual(False, valid2) + + @gen_test + def test_validate_when_update_auth_and_no_access_token(self): + authenticator = create_test_authenticator(auth_info_ttl=1) + + request_handler = mock_request_handler('X') + username = yield authenticator.authenticate(request_handler) + + valid = authenticator.validate_user(username, mock_request_handler('X')) + self.assertEqual(False, valid) + + +class TestUpdateUserAuth(_OauthTestCase): + + @patch('time.time', mock_time) + @gen_test + def test_user_becomes_prohibited(self): + valid = yield self.run_validation_test( + lambda username, authenticator: authenticator.disabled_users.append(username)) + + self.assertEqual(False, valid) + + @patch('time.time', mock_time) + @gen_test + def test_user_stays_active(self): + valid = yield self.run_validation_test(lambda username, authenticator: None) + + self.assertEqual(True, valid) + + @patch('time.time', mock_time) + @gen_test + def test_user_removed(self): + def remove_user(username, authenticator): + del authenticator.user_tokens['11111'] + + valid = yield self.run_validation_test(remove_user) + + self.assertEqual(False, valid) + + @patch('time.time', mock_time) + @gen_test + def test_user_no_email(self): + def remove_user_email(username, authenticator): + authenticator.user_tokens['11111'] = '' + + valid = yield self.run_validation_test(remove_user_email) + + self.assertEqual(False, valid) + + @patch('time.time', mock_time) + @gen_test + def test_reload_groups(self): + this_auth = None # type: MockOauthAuthenticator + + def change_groups(username, authenticator): + authenticator.user_groups['user_X'] = ['Group A'] + nonlocal this_auth + this_auth = authenticator + + valid = yield self.run_validation_test(change_groups) + + self.assertEqual(True, valid) + self.assertEqual(['Group A'], this_auth.get_groups('user_X')) + + @patch('time.time', mock_time) + @gen_test + def test_reload_groups_fails(self): + def set_groups_loading_fail(username, authenticator): + authenticator.failing_groups_loading.append(username) + + valid = yield self.run_validation_test(set_groups_loading_fail) + + self.assertEqual(False, valid) + + @patch('time.time', mock_time) + @gen_test + def test_no_reload_groups_without_expiry(self): + authenticator = create_test_authenticator(auth_info_ttl=5) + authenticator.user_groups['user_X'] = ['group1', 'group2'] + + request_handler = mock_request_handler('X') + username = yield authenticator.authenticate(request_handler) + + mock_time.return_value = mock_time.return_value + 2 + + authenticator.user_groups['user_X'] = ['Group A'] + + valid1 = authenticator.validate_user(username, request_handler) + self.assertEqual(True, valid1) + + yield self.wait_next_ioloop() + + groups = authenticator.get_groups('user_X') + self.assertEqual(['group1', 'group2'], groups) + + @gen.coroutine + def run_validation_test(self, prevalidation_callback): + authenticator = create_test_authenticator(auth_info_ttl=5) + + request_handler = mock_request_handler('X') + username = yield authenticator.authenticate(request_handler) + + mock_time.return_value = mock_time.return_value + 10 + + prevalidation_callback(username, authenticator) + + valid1 = authenticator.validate_user(username, request_handler) + self.assertEqual(True, valid1) + + yield self.wait_next_ioloop() + + return authenticator.validate_user(username, request_handler) + + async def wait_next_ioloop(self): + await gen.sleep(0.001) + + +class TestLogout(_OauthTestCase): + @gen_test + def test_validate_user_success(self): + authenticator = create_test_authenticator() + + request_handler = mock_request_handler('X') + username = yield authenticator.authenticate(request_handler) + + authenticator.logout(username, request_handler) + + self.assertIsNone(request_handler.get_secure_cookie('token')) + + valid = authenticator.validate_user(username, request_handler) + self.assertFalse(valid) + + +class TestDump(_OauthTestCase): + @gen_test + def test_validate_empty_dump(self): + dump_file = os.path.join(test_utils.temp_folder, 'dump.json') + create_test_authenticator(dump_file=dump_file) + + self.wait_dump() + + self.validate_dump(dump_file, []) + + @patch('time.time', mock_time) + @gen_test + def test_validate_single_user(self): + dump_file = os.path.join(test_utils.temp_folder, 'dump.json') + authenticator = create_test_authenticator(dump_file=dump_file) + + yield authenticator.authenticate(mock_request_handler('X')) + + self.wait_dump() + + self.validate_dump(dump_file, [{ + 'username': 'user_X', + 'last_visit': 10000.01, + 'last_auth_update': None, + 'groups': []}]) + + @patch('time.time', mock_time) + @gen_test + def test_validate_2_users(self): + dump_file = os.path.join(test_utils.temp_folder, 'dump.json') + authenticator = create_test_authenticator(dump_file=dump_file, auth_info_ttl=1) + + yield authenticator.authenticate(mock_request_handler('X')) + + mock_time.return_value = 10002.02 + authenticator.user_groups['user_Y'] = ['Group A'] + + yield authenticator.authenticate(mock_request_handler('Y')) + + self.wait_dump() + + self.validate_dump(dump_file, [{ + 'username': 'user_X', + 'last_visit': 10000.01, + 'last_auth_update': 10000.01, + 'groups': []}, + { + 'username': 'user_Y', + 'last_visit': 10002.02, + 'last_auth_update': 10002.02, + 'groups': ['Group A'] + }]) + + @patch('time.time', mock_time) + @gen_test + def test_validate_after_logout(self): + dump_file = os.path.join(test_utils.temp_folder, 'dump.json') + authenticator = create_test_authenticator(dump_file=dump_file, auth_info_ttl=1) + + user_x_request_handler = mock_request_handler('X') + yield authenticator.authenticate(user_x_request_handler) + + mock_time.return_value = 10002.02 + authenticator.user_groups['user_Y'] = ['Group A'] + + yield authenticator.authenticate(mock_request_handler('Y')) + + authenticator.logout('user_X', user_x_request_handler) + + self.validate_dump(dump_file, [{ + 'username': 'user_Y', + 'last_visit': 10002.02, + 'last_auth_update': 10002.02, + 'groups': ['Group A'] + }]) + + def validate_dump(self, dump_file, expected_value): + self.assertTrue(os.path.exists(dump_file)) + file_content = file_utils.read_file(dump_file) + restored_dump = json.loads(file_content) + restored_dump.sort(key=lambda state: state['username']) + self.assertEqual(expected_value, restored_dump) + + def wait_dump(self): + invocations = self.timer_invocations + + wait_count = 0 + while (self.timer_invocations == invocations) and (wait_count < 50): + time.sleep(0.001) + + def setUp(self) -> None: + super().setUp() + + self._def_start_timer = auth.auth_abstract_oauth._start_timer + + self.timer_invocations = 0 + self.max_timer_invocations = 9999 + + def start_quick_timer(callback): + if self.timer_invocations > self.max_timer_invocations: + return + + timer = threading.Timer(0.01, callback) + timer.setDaemon(True) + timer.start() + + self.timer_invocations += 1 + + return timer + + auth.auth_abstract_oauth._start_timer = start_quick_timer + + def tearDown(self): + super().tearDown() + + auth.auth_abstract_oauth._start_timer = self._def_start_timer + + +class MockOauthAuthenticator(AbstractOauthAuthenticator): + def __init__(self, params_dict): + super().__init__('authorize_url', 'token_url', 'test_scope', params_dict) + + self.random_instance = random.seed(a=123) + + self.user_tokens = { + '11111': 'user_X', + '22222': 'user_Y', + '33333': 'user_Z' + } + self.user_groups = {} + self.disabled_users = [] + self.failing_groups_loading = [] + + async def fetch_access_token(self, code, request_handler): + for key, value in self.user_tokens.items(): + if value.endswith(code): + return key + + raise Exception('Could not generate token for code ' + code + '. Make sure core is equal to user suffix') + + async def fetch_user_info(self, access_token: str) -> _OauthUserInfo: + if access_token not in self.user_tokens: + return None + + user = self.user_tokens[access_token] + + enabled = user not in self.disabled_users + return _OauthUserInfo(user, enabled, {'username': user, 'access_token': access_token}) + + async def fetch_user_groups(self, access_token): + user = self.user_tokens[access_token] + + if user in self.failing_groups_loading: + raise AuthFailureError('Emulate group loading error') + + if user in self.user_groups: + return self.user_groups[user] + return [] diff --git a/src/tests/auth/test_auth_gitlab.py b/src/tests/auth/test_auth_gitlab.py new file mode 100644 index 00000000..10030f05 --- /dev/null +++ b/src/tests/auth/test_auth_gitlab.py @@ -0,0 +1,117 @@ +import unittest +from unittest.mock import patch + +# noinspection PyProtectedMember +from tornado.testing import AsyncTestCase, gen_test + +# noinspection PyProtectedMember +from auth.auth_abstract_oauth import _OauthUserInfo +from auth.auth_gitlab import GitlabOAuthAuthenticator +from tests.test_utils import AsyncMock + + +def create_config(*, url=None, group_search=None, group_support=None): + config = { + 'client_id': '1234', + 'secret': 'hello world?' + } + + if url is not None: + config['url'] = url + if group_search is not None: + config['group_search'] = group_search + if group_support is not None: + config['group_support'] = group_support + + return config + + +class TestAuthConfig(unittest.TestCase): + def test_client_visible_config(self): + authenticator = GitlabOAuthAuthenticator(create_config(url='https://my.gitlab.host')) + + client_visible_config = authenticator._client_visible_config + self.assertEqual('1234', client_visible_config['client_id']) + self.assertEqual('https://my.gitlab.host/oauth/authorize', client_visible_config['oauth_url']) + self.assertEqual('api', client_visible_config['oauth_scope']) + + def test_client_visible_config_when_groups_disabled(self): + authenticator = GitlabOAuthAuthenticator(create_config(group_support=False)) + + client_visible_config = authenticator._client_visible_config + self.assertEqual('read_user', client_visible_config['oauth_scope']) + + def test_client_visible_config_when_default_url(self): + authenticator = GitlabOAuthAuthenticator(create_config()) + + client_visible_config = authenticator._client_visible_config + self.assertEqual('https://gitlab.com/oauth/authorize', client_visible_config['oauth_url']) + + +class TestFetchUserInfo(AsyncTestCase): + @patch('tornado.auth.OAuth2Mixin.oauth2_request', new_callable=AsyncMock) + @gen_test + def test_fetch_user_info(self, mock_request): + response = {'email': 'me@gmail.com', 'state': 'active'} + mock_request.return_value = response + + authenticator = GitlabOAuthAuthenticator(create_config(url='https://my.gitlab.host')) + + user_info = yield authenticator.fetch_user_info('my_token_2') + self.assertEqual(_OauthUserInfo('me@gmail.com', True, response), user_info) + + mock_request.assert_called_with('https://my.gitlab.host/api/v4/user', 'my_token_2') + + @patch('tornado.auth.OAuth2Mixin.oauth2_request', new_callable=AsyncMock) + @gen_test + def test_fetch_user_info_when_no_response(self, mock_request): + mock_request.return_value = None + + authenticator = GitlabOAuthAuthenticator(create_config()) + + user_info = yield authenticator.fetch_user_info('my_token_2') + self.assertEqual(None, user_info) + + @patch('tornado.auth.OAuth2Mixin.oauth2_request', new_callable=AsyncMock) + @gen_test + def test_fetch_user_info_when_not_active(self, mock_request): + response = {'email': 'me@gmail.com', 'state': 'something'} + mock_request.return_value = response + + authenticator = GitlabOAuthAuthenticator(create_config()) + + user_info = yield authenticator.fetch_user_info('my_token_2') + self.assertEqual(_OauthUserInfo('me@gmail.com', False, response), user_info) + + +class TestFetchUserGroups(AsyncTestCase): + @patch('tornado.auth.OAuth2Mixin.oauth2_request', new_callable=AsyncMock) + @gen_test + def test_fetch_user_groups(self, mock_request): + response = [{'full_path': 'group1'}, {'full_path': 'group2'}, {'something': 'group3'}] + mock_request.return_value = response + + authenticator = GitlabOAuthAuthenticator(create_config(url='https://my.gitlab.host')) + + groups = yield authenticator.fetch_user_groups('my_token_2') + self.assertEqual(['group1', 'group2'], groups) + + mock_request.assert_called_with('https://my.gitlab.host/api/v4/groups', + access_token='my_token_2', + all_available='false', + per_page=100) + + @patch('tornado.auth.OAuth2Mixin.oauth2_request', new_callable=AsyncMock) + @gen_test + def test_fetch_user_groups_when_search(self, mock_request): + mock_request.return_value = [] + + authenticator = GitlabOAuthAuthenticator(create_config(url='https://my.gitlab.host', group_search='abc')) + + yield authenticator.fetch_user_groups('my_token_2') + + mock_request.assert_called_with('https://my.gitlab.host/api/v4/groups', + access_token='my_token_2', + all_available='false', + per_page=100, + search='abc') diff --git a/src/tests/server_conf_test.py b/src/tests/server_conf_test.py index 566b68a2..5531ac90 100644 --- a/src/tests/server_conf_test.py +++ b/src/tests/server_conf_test.py @@ -2,6 +2,7 @@ import os import unittest +from auth.auth_gitlab import GitlabOAuthAuthenticator from auth.auth_google_oauth import GoogleOauthAuthenticator from auth.auth_htpasswd import HtpasswdAuthenticator from auth.auth_ldap import LdapAuthenticator @@ -203,6 +204,19 @@ def test_google_oauth_without_allowed_users(self): 'client_id': '1234', 'secret': 'abcd'}}) + def test_gitlab_oauth(self): + config = _from_json({ + 'auth': { + 'type': 'gitlab', + 'client_id': '1234', + 'secret': 'abcd', + }, + 'access': { + 'allowed_users': [] + }}) + + self.assertIsInstance(config.authenticator, GitlabOAuthAuthenticator) + def test_ldap(self): config = _from_json({'auth': {'type': 'ldap', 'url': 'http://test-ldap.net', diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py index 887b89b4..62c6f88f 100644 --- a/src/tests/test_utils.py +++ b/src/tests/test_utils.py @@ -6,6 +6,7 @@ import uuid from copy import copy from unittest.case import TestCase +from unittest.mock import MagicMock import utils.file_utils as file_utils import utils.os_utils as os_utils @@ -488,3 +489,8 @@ def next_id(self): self._next_id += 1 self.generated_ids.append(id) return id + + +class AsyncMock(MagicMock): + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) diff --git a/src/web/server.py b/src/web/server.py index 4fc75382..a2e7314d 100755 --- a/src/web/server.py +++ b/src/web/server.py @@ -106,6 +106,13 @@ def wrapper(self, *args, **kwargs): auth = self.application.auth authorizer = self.application.authorizer + login_url = self.get_login_url() + request_path = self.request.path + + login_resource = is_allowed_during_login(request_path, login_url, self) + if login_resource: + return func(self, *args, **kwargs) + authenticated = auth.is_authenticated(self) access_allowed = authenticated and authorizer.is_allowed_in_app(_identify_user(self)) @@ -119,11 +126,7 @@ def wrapper(self, *args, **kwargs): else: raise tornado.web.HTTPError(code, message) - login_url = self.get_login_url() - request_path = self.request.path - - login_resource = is_allowed_during_login(request_path, login_url, self) - if (authenticated and access_allowed) or login_resource: + if authenticated and access_allowed: return func(self, *args, **kwargs) if not isinstance(self, tornado.web.StaticFileHandler): @@ -695,10 +698,15 @@ def get(self): if auth.is_enabled(): username = auth.get_username(self) + try: + admin_rights = has_admin_rights(self) + except Exception: + admin_rights = False + info = { 'enabled': auth.is_enabled(), 'username': username, - 'admin': has_admin_rights(self) + 'admin': admin_rights } self.write(info) diff --git a/web-src/public/login.html b/web-src/public/login.html index 04904bff..1c5df7dd 100644 --- a/web-src/public/login.html +++ b/web-src/public/login.html @@ -43,4 +43,11 @@ + + \ No newline at end of file diff --git a/web-src/src/assets/css/index.css b/web-src/src/assets/css/index.css index da2b1fc9..4d278d3e 100644 --- a/web-src/src/assets/css/index.css +++ b/web-src/src/assets/css/index.css @@ -156,3 +156,40 @@ input[type=checkbox]:not(.browser-default) + span { #login-google_oauth-button[disabled] { color: #B0B0B0; } + + +#login-panel .login-gitlab .login-info-label { + margin-top: 16px; +} + +#login-gitlab-button { + height: 40px; + width: 188px; + padding-left: 34px; + margin: auto; + margin-top: 34px; + display: block; + + font-size: 14px; + font-weight: 500; + color: #757575; + + border-radius: 2px; + box-shadow: 0 1px 3px -1px #202020; + border: none; + + background-image: url('../gitlab-icon-rgb.png'); + background-color: white; + background-position-y: 50%; + background-position-x: -4px; + background-size: 48px; + background-repeat: no-repeat; +} + +#login-gitlab-button:active { + background-color: #EEE; +} + +#login-gitlab-button[disabled] { + color: #B0B0B0; +} diff --git a/web-src/src/assets/gitlab-icon-rgb.png b/web-src/src/assets/gitlab-icon-rgb.png new file mode 100644 index 00000000..21a02db5 Binary files /dev/null and b/web-src/src/assets/gitlab-icon-rgb.png differ diff --git a/web-src/src/login/login.js b/web-src/src/login/login.js index 20cd8c65..94b89572 100644 --- a/web-src/src/login/login.js +++ b/web-src/src/login/login.js @@ -30,6 +30,8 @@ function onLoad() { var config = JSON.parse(configResponse); if (config['type'] === 'google_oauth') { setupGoogleOAuth(loginContainer, config); + } else if (config['type'] === 'gitlab') { + setupGitlabOAuth(loginContainer, config); } else { setupCredentials(loginContainer); } @@ -55,10 +57,26 @@ function setupCredentials(loginContainer) { } function setupGoogleOAuth(loginContainer, authConfig) { - var credentialsTemplate = createTemplateElement('login-google_oauth-template'); + setupOAuth( + loginContainer, + authConfig, + 'login-google_oauth-template', + 'login-google_oauth-button') +} + +function setupGitlabOAuth(loginContainer, authConfig) { + setupOAuth( + loginContainer, + authConfig, + 'login-gitlab-template', + 'login-gitlab-button') +} + +function setupOAuth(loginContainer, authConfig, templateName, buttonId) { + var credentialsTemplate = createTemplateElement(templateName); loginContainer.appendChild(credentialsTemplate); - var oauthLoginButton = document.getElementById('login-google_oauth-button'); + var oauthLoginButton = document.getElementById(buttonId); oauthLoginButton.onclick = function () { var token = guid(32);