diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..43d4b1a8 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,31 @@ +[run] +branch = True +source = + alliance_auth + authentication + corputils + eveonline + fleetactivitytracking + fleetup + groupmanagement + hrapplications + notifications + optimer + services + srp + timerboard + +omit = + */migrations/* + */example/* + +[report] +exclude_lines = + if self.debug: + pragma: no cover + raise NotImplementedError + if __name__ == .__main__.: + def __repr__ + raise AssertionError + +ignore_errors = True diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..6ca8c709 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: python +python: + - "2.7" + - "3.5" +# command to install dependencies +install: + - pip install requests + - pip install -r requirements.txt + - pip install -r testing-requirements.txt +# command to run tests +script: coverage run runtests.py +cache: pip +after_success: + coveralls diff --git a/alliance_auth/__init__.py b/alliance_auth/__init__.py index 97dcd825..3eac6fde 100644 --- a/alliance_auth/__init__.py +++ b/alliance_auth/__init__.py @@ -1,4 +1,8 @@ -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals + +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celeryapp import app as celery_app # noqa __version__ = '1.14.2' NAME = 'Alliance Auth v%s' % __version__ diff --git a/alliance_auth/celeryapp.py b/alliance_auth/celeryapp.py new file mode 100644 index 00000000..26a34e0d --- /dev/null +++ b/alliance_auth/celeryapp.py @@ -0,0 +1,17 @@ +from __future__ import absolute_import, unicode_literals +import os +from celery import Celery + +# set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'alliance_auth.settings') + +from django.conf import settings # noqa + +app = Celery('alliance_auth') + +# Using a string here means the worker don't have to serialize +# the configuration object to child processes. +app.config_from_object('django.conf:settings') + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) diff --git a/alliance_auth/hooks.py b/alliance_auth/hooks.py new file mode 100644 index 00000000..b10afaa9 --- /dev/null +++ b/alliance_auth/hooks.py @@ -0,0 +1,127 @@ +""" +Copyright (c) 2014 Torchbox Ltd and individual contributors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the name of Torchbox nor the names of its contributors may be used + to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Based on https://github.com/torchbox/wagtail/blob/master/wagtail/wagtailcore/hooks.py +""" + +from __future__ import unicode_literals + +from importlib import import_module + +from django.apps import apps +from django.utils.module_loading import module_has_submodule + +import logging + +logger = logging.getLogger(__name__) + +_hooks = {} # Dict of Name: Fn's of registered hooks + +_all_hooks_registered = False # If all hooks have been searched for and registered yet + + +def register(name, fn=None): + """ + Decorator to register a function as a hook + + Register hook for ``hook_name``. Can be used as a decorator:: + @register('hook_name') + def my_hook(...): + pass + + or as a function call:: + def my_hook(...): + pass + register('hook_name', my_hook) + + :param name: str Name of the hook/callback to register it as + :param fn: function to register in the hook/callback + :return: function Decorator if applied as a decorator + """ + def _hook_add(func): + if name not in _hooks: + logger.debug("Creating new hook %s" % name) + _hooks[name] = [] + + logger.debug('Registering hook %s for function %s' % (name, fn)) + _hooks[name].append(func) + + if fn is None: + # Behave like a decorator + def decorator(func): + _hook_add(func) + return func + return decorator + else: + # Behave like a function, just register hook + _hook_add(fn) + + +def get_app_modules(): + """ + Get all the modules of the django app + :return: name, module tuple + """ + for app in apps.get_app_configs(): + yield app.name, app.module + + +def get_app_submodules(module_name): + """ + Get a specific sub module of the app + :param module_name: module name to get + :return: name, module tuple + """ + for name, module in get_app_modules(): + if module_has_submodule(module, module_name): + yield name, import_module('{0}.{1}'.format(name, module_name)) + + +def register_all_hooks(): + """ + Register all hooks found in 'auth_hooks' sub modules + :return: + """ + global _all_hooks_registered + if not _all_hooks_registered: + logger.debug("Searching for hooks") + hooks = list(get_app_submodules('auth_hooks')) + logger.debug("Got %s hooks" % len(hooks)) + _all_hooks_registered = True + + +def get_hooks(name): + """ + Get all the hook functions for the given hook name + :param name: str name of the hook to get the functions for + :return: list of hook functions + """ + register_all_hooks() + return _hooks.get(name, []) + diff --git a/alliance_auth/settings.py.example b/alliance_auth/settings.py.example index 54dc4b15..e4fe0d42 100644 --- a/alliance_auth/settings.py.example +++ b/alliance_auth/settings.py.example @@ -15,12 +15,22 @@ import os import djcelery from django.contrib import messages +from celery.schedules import crontab djcelery.setup_loader() # Celery configuration BROKER_URL = 'redis://localhost:6379/0' CELERYBEAT_SCHEDULER = "djcelery.schedulers.DatabaseScheduler" +CELERYBEAT_SCHEDULE = { + """ + Uncomment this if you are using the Teamspeak3 service + 'run_ts3_group_update': { + 'task': 'services.modules.teamspeak3.tasks.Teamspeak3Tasks.run_ts3_group_update', + 'schedule': crontab(minute='*/30'), + }, + """ +} # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -64,6 +74,19 @@ INSTALLED_APPS = [ 'esi', 'geelweb.django.navhelper', 'bootstrap_pagination', + + # Services + 'services.modules.mumble', + 'services.modules.discord', + 'services.modules.discourse', + 'services.modules.ipboard', + 'services.modules.ips4', + 'services.modules.market', + 'services.modules.openfire', + 'services.modules.smf', + 'services.modules.phpbb3', + 'services.modules.xenforo', + 'services.modules.teamspeak3', ] MIDDLEWARE = [ diff --git a/alliance_auth/tests/__init__.py b/alliance_auth/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/alliance_auth/tests/auth_utils.py b/alliance_auth/tests/auth_utils.py new file mode 100644 index 00000000..c3ca78a3 --- /dev/null +++ b/alliance_auth/tests/auth_utils.py @@ -0,0 +1,81 @@ +from __future__ import unicode_literals + +from django.db.models.signals import m2m_changed, pre_save +from django.contrib.auth.models import User + +from services.signals import m2m_changed_user_groups, pre_save_user +from authentication.signals import pre_save_auth_state + +from authentication.tasks import make_member, make_blue +from authentication.models import AuthServicesInfo +from authentication.states import MEMBER_STATE, BLUE_STATE, NONE_STATE + +from eveonline.models import EveCharacter + + +class AuthUtils: + def __init__(self): + pass + + @staticmethod + def _create_user(username): + return User.objects.create(username=username) + + @classmethod + def create_user(cls, username, disconnect_signals=False): + if disconnect_signals: + cls.disconnect_signals() + user = cls._create_user(username) + user.authservicesinfo.state = NONE_STATE + user.authservicesinfo.save() + if disconnect_signals: + cls.connect_signals() + return user + + @classmethod + def create_member(cls, username): + cls.disconnect_signals() + user = cls._create_user(username) + user.authservicesinfo.state = MEMBER_STATE + user.authservicesinfo.save() + make_member(user.authservicesinfo) + cls.connect_signals() + return user + + @classmethod + def create_blue(cls, username): + cls.disconnect_signals() + user = cls._create_user(username) + user.authservicesinfo.state = BLUE_STATE + user.authservicesinfo.save() + make_blue(user.authservicesinfo) + cls.connect_signals() + return user + + @classmethod + def disconnect_signals(cls): + m2m_changed.disconnect(m2m_changed_user_groups, sender=User.groups.through) + pre_save.disconnect(pre_save_user, sender=User) + pre_save.disconnect(pre_save_auth_state, sender=AuthServicesInfo) + + @classmethod + def connect_signals(cls): + m2m_changed.connect(m2m_changed_user_groups, sender=User.groups.through) + pre_save.connect(pre_save_user, sender=User) + pre_save.connect(pre_save_auth_state, sender=AuthServicesInfo) + + @classmethod + def add_main_character(cls, user, name, character_id, corp_id='', corp_name='', corp_ticker='', alliance_id='', + alliance_name=''): + EveCharacter.objects.create( + character_id=character_id, + character_name=name, + corporation_id=corp_id, + corporation_name=corp_name, + corporation_ticker=corp_ticker, + alliance_id=alliance_id, + alliance_name=alliance_name, + api_id='1234', + user=user + ) + AuthServicesInfo.objects.update_or_create(user=user, defaults={'main_char_id': character_id}) diff --git a/alliance_auth/tests/test_settings.py b/alliance_auth/tests/test_settings.py new file mode 100644 index 00000000..f150cfd4 --- /dev/null +++ b/alliance_auth/tests/test_settings.py @@ -0,0 +1,604 @@ +""" +Alliance Auth Test Suite Django settings. +""" + +import os + +import djcelery + +from django.contrib import messages + +import alliance_auth + +djcelery.setup_loader() + +# Use nose to run all tests +TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' + +NOSE_ARGS = [ + #'--with-coverage', + #'--cover-package=', +] + +# Celery configuration +CELERY_ALWAYS_EAGER = True # Forces celery to run locally for testing + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(alliance_auth.__file__))) + +SECRET_KEY = 'testing only' + +DEBUG = True + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.humanize', + 'djcelery', + 'bootstrapform', + 'authentication', + 'services', + 'eveonline', + 'groupmanagement', + 'hrapplications', + 'timerboard', + 'srp', + 'optimer', + 'corputils', + 'fleetactivitytracking', + 'notifications', + 'esi', + 'geelweb.django.navhelper', + 'bootstrap_pagination', + 'services.modules.mumble', + 'services.modules.discord', + 'services.modules.discourse', + 'services.modules.ipboard', + 'services.modules.ips4', + 'services.modules.market', + 'services.modules.openfire', + 'services.modules.smf', + 'services.modules.phpbb3', + 'services.modules.xenforo', + 'services.modules.teamspeak3', + 'django_nose', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.locale.LocaleMiddleware', +] + +ROOT_URLCONF = 'alliance_auth.urls' + +LOCALE_PATHS = ( + os.path.join(BASE_DIR, 'locale/'), +) + +ugettext = lambda s: s +LANGUAGES = ( + ('en', ugettext('English')), + ('de', ugettext('German')), +) + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(BASE_DIR, 'customization/templates'), + os.path.join(BASE_DIR, 'stock/templates'), + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'django.template.context_processors.i18n', + 'django.template.context_processors.media', + 'django.template.context_processors.static', + 'django.template.context_processors.tz', + 'services.context_processors.auth_settings', + 'notifications.context_processors.user_notification_count', + 'authentication.context_processors.states', + 'authentication.context_processors.membership_state', + 'groupmanagement.context_processors.can_manage_groups', + ], + }, + }, +] + +# Database +# https://docs.djangoproject.com/en/1.10/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'alliance_auth', + 'USER': os.environ.get('AA_DB_DEFAULT_USER', None), + 'PASSWORD': os.environ.get('AA_DB_DEFAULT_PASSWORD', None), + 'HOST': os.environ.get('AA_DB_DEFAULT_HOST', None) + }, +} + +LOGIN_URL = 'auth_login_user' + +SUPERUSER_STATE_BYPASS = 'True' == os.environ.get('AA_SUPERUSER_STATE_BYPASS', 'True') + +# Internationalization +# https://docs.djangoproject.com/en/1.10/topics/i18n/ + +LANGUAGE_CODE = os.environ.get('AA_LANGUAGE_CODE', 'en-us') + +TIME_ZONE = os.environ.get('AA_TIME_ZONE', 'UTC') + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) + +STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, "static") +STATICFILES_DIRS = ( + os.path.join(BASE_DIR, "customization/static"), + os.path.join(BASE_DIR, "stock/static"), +) + +# Bootstrap messaging css workaround +MESSAGE_TAGS = { + messages.ERROR: 'danger' +} + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + } +} + +##################################################### +## +## Auth configuration starts here +## +##################################################### + +########################### +# ALLIANCE / CORP TOGGLE +########################### +# Specifies to run membership checks against corp or alliance +# Set to FALSE for alliance +# Set to TRUE for corp +########################### +IS_CORP = 'True' == os.environ.get('AA_IS_CORP', 'True') + +################# +# EMAIL SETTINGS +################# +# DOMAIN - The alliance auth domain_url +# EMAIL_HOST - SMTP Server URL +# EMAIL_PORT - SMTP Server PORT +# EMAIL_HOST_USER - Email Username (for gmail, the entire address) +# EMAIL_HOST_PASSWORD - Email Password +# EMAIL_USE_TLS - Set to use TLS encryption +################# +DOMAIN = os.environ.get('AA_DOMAIN', 'https://example.com') +EMAIL_HOST = os.environ.get('AA_EMAIL_HOST', 'smtp.example.com') +EMAIL_PORT = int(os.environ.get('AA_EMAIL_PORT', '587')) +EMAIL_HOST_USER = os.environ.get('AA_EMAIL_HOST_USER', '') +EMAIL_HOST_PASSWORD = os.environ.get('AA_EMAIL_HOST_PASSWORD', '') +EMAIL_USE_TLS = 'True' == os.environ.get('AA_EMAIL_USE_TLS', 'True') + +#################### +# Front Page Links +#################### +# KILLBOARD_URL - URL for your killboard. Blank to hide link +# MEDIA_URL - URL for your media page (youtube etc). Blank to hide link +# FORUM_URL - URL for your forums. Blank to hide link +# SITE_NAME - Name of the auth site. +#################### +KILLBOARD_URL = os.environ.get('AA_KILLBOARD_URL', '') +EXTERNAL_MEDIA_URL = os.environ.get('AA_EXTERNAL_MEDIA_URL', '') +FORUM_URL = os.environ.get('AA_FORUM_URL', '') +SITE_NAME = os.environ.get('AA_SITE_NAME', 'Test Alliance Auth') + +################### +# SSO Settings +################### +# Optional SSO. +# Get client ID and client secret from registering an app at +# https://developers.eveonline.com/ +# Callback URL should be http://mydomain.com/sso/callback +# Leave callback blank to hide SSO button on login page +################### +ESI_SSO_CLIENT_ID = os.environ.get('AA_ESI_SSO_CLIENT_ID', '') +ESI_SSO_CLIENT_SECRET = os.environ.get('AA_ESI_SSO_CLIENT_SECRET', '') +ESI_SSO_CALLBACK_URL = os.environ.get('AA_ESI_SSO_CALLBACK_URL', '') + +######################### +# Default Group Settings +######################### +# DEFAULT_AUTH_GROUP - Default group members are put in +# DEFAULT_BLUE_GROUP - Default group for blue members +# MEMBER_CORP_GROUPS - Assign members to a group representing their main corp +# BLUE_CORP_GROUPS - Assign blues to a group representing their main corp +######################### +DEFAULT_AUTH_GROUP = os.environ.get('AA_DEFAULT_ALLIANCE_GROUP', 'Member') +DEFAULT_BLUE_GROUP = os.environ.get('AA_DEFAULT_BLUE_GROUP', 'Blue') +MEMBER_CORP_GROUPS = 'True' == os.environ.get('AA_MEMBER_CORP_GROUPS', 'True') +MEMBER_ALLIANCE_GROUPS = 'True' == os.environ.get('AA_MEMBER_ALLIANCE_GROUPS', 'False') +BLUE_CORP_GROUPS = 'True' == os.environ.get('AA_BLUE_CORP_GROUPS', 'False') +BLUE_ALLIANCE_GROUPS = 'True' == os.environ.get('AA_BLUE_ALLIANCE_GROUPS', 'False') + +######################### +# Alliance Service Setup +######################### +# ENABLE_AUTH_FORUM - Enable forum support in the auth for auth'd members +# ENABLE_AUTH_JABBER - Enable jabber support in the auth for auth'd members +# ENABLE_AUTH_MUMBLE - Enable mumble support in the auth for auth'd members +# ENABLE_AUTH_IPBOARD - Enable IPBoard forum support in the auth for auth'd members +# ENABLE_AUTH_DISCORD - Enable Discord support in the auth for auth'd members +# ENABLE_AUTH_DISCOURSE - Enable Discourse support in the auth for auth'd members +# ENABLE_AUTH_IPS4 - Enable IPS4 support in the auth for auth'd members +# ENABLE_AUTH_SMF - Enable SMF forum support in the auth for auth'd members +# ENABLE_AUTH_MARKET = Enable Alliance Market support in auth for auth'd members +# ENABLE_AUTH_PATHFINDER = Enable Alliance Pathfinder suppor in auth for auth'd members +# ENABLE_AUTH_XENFORO = Enable XenForo forums support in the auth for auth'd members +######################### +ENABLE_AUTH_FORUM = 'True' == os.environ.get('AA_ENABLE_AUTH_FORUM', 'True') +ENABLE_AUTH_JABBER = 'True' == os.environ.get('AA_ENABLE_AUTH_JABBER', 'True') +ENABLE_AUTH_MUMBLE = 'True' == os.environ.get('AA_ENABLE_AUTH_MUMBLE', 'True') +ENABLE_AUTH_IPBOARD = 'True' == os.environ.get('AA_ENABLE_AUTH_IPBOARD', 'True') +ENABLE_AUTH_TEAMSPEAK3 = 'True' == os.environ.get('AA_ENABLE_AUTH_TEAMSPEAK3', 'True') +ENABLE_AUTH_DISCORD = 'True' == os.environ.get('AA_ENABLE_AUTH_DISCORD', 'True') +ENABLE_AUTH_DISCOURSE = 'True' == os.environ.get('AA_ENABLE_AUTH_DISCOURSE', 'True') +ENABLE_AUTH_IPS4 = 'True' == os.environ.get('AA_ENABLE_AUTH_IPS4', 'True') +ENABLE_AUTH_SMF = 'True' == os.environ.get('AA_ENABLE_AUTH_SMF', 'True') +ENABLE_AUTH_MARKET = 'True' == os.environ.get('AA_ENABLE_AUTH_MARKET', 'True') +ENABLE_AUTH_XENFORO = 'True' == os.environ.get('AA_ENABLE_AUTH_XENFORO', 'True') + +##################### +# Blue service Setup +##################### +# BLUE_STANDING - The default lowest standings setting to consider blue +# ENABLE_BLUE_FORUM - Enable forum support in the auth for blues +# ENABLE_BLUE_JABBER - Enable jabber support in the auth for blues +# ENABLE_BLUE_MUMBLE - Enable mumble support in the auth for blues +# ENABLE_BLUE_IPBOARD - Enable IPBoard forum support in the auth for blues +# ENABLE_BLUE_DISCORD - Enable Discord support in the auth for blues +# ENABLE_BLUE_DISCOURSE - Enable Discord support in the auth for blues +# ENABLE_BLUE_IPS4 - Enable IPS4 forum support in the auth for blues +# ENABLE_BLUE_SMF - Enable SMF forum support in the auth for blues +# ENABLE_BLUE_MARKET - Enable Alliance Market in the auth for blues +# ENABLE_BLUE_PATHFINDER = Enable Pathfinder support in the auth for blues +# ENABLE_BLUE_XENFORO = Enable XenForo forum support in the auth for blue +##################### +BLUE_STANDING = float(os.environ.get('AA_BLUE_STANDING', '5.0')) +ENABLE_BLUE_FORUM = 'True' == os.environ.get('AA_ENABLE_BLUE_FORUM', 'True') +ENABLE_BLUE_JABBER = 'True' == os.environ.get('AA_ENABLE_BLUE_JABBER', 'True') +ENABLE_BLUE_MUMBLE = 'True' == os.environ.get('AA_ENABLE_BLUE_MUMBLE', 'True') +ENABLE_BLUE_IPBOARD = 'True' == os.environ.get('AA_ENABLE_BLUE_IPBOARD', 'True') +ENABLE_BLUE_TEAMSPEAK3 = 'True' == os.environ.get('AA_ENABLE_BLUE_TEAMSPEAK3', 'True') +ENABLE_BLUE_DISCORD = 'True' == os.environ.get('AA_ENABLE_BLUE_DISCORD', 'True') +ENABLE_BLUE_DISCOURSE = 'True' == os.environ.get('AA_ENABLE_BLUE_DISCOURSE', 'True') +ENABLE_BLUE_IPS4 = 'True' == os.environ.get('AA_ENABLE_BLUE_IPS4', 'True') +ENABLE_BLUE_SMF = 'True' == os.environ.get('AA_ENABLE_BLUE_SMF', 'True') +ENABLE_BLUE_MARKET = 'True' == os.environ.get('AA_ENABLE_BLUE_MARKET', 'True') +ENABLE_BLUE_XENFORO = 'True' == os.environ.get('AA_ENABLE_BLUE_XENFORO', 'True') + +######################### +# Corp Configuration +######################### +# If running in alliance mode, the following should be for the executor corp# +# CORP_ID - Set this to your corp ID (get this from https://zkillboard.com/corporation/#######) +# CORP_NAME - Set this to your Corporation Name +# CORP_API_ID - Set this to the api id for the corp API key +# CORP_API_VCODE - Set this to the api vcode for the corp API key +######################## +CORP_ID = os.environ.get('AA_CORP_ID', '1234') +CORP_NAME = os.environ.get('AA_CORP_NAME', 'Alliance Auth Test Corp') +CORP_API_ID = os.environ.get('AA_CORP_API_ID', '') +CORP_API_VCODE = os.environ.get('AA_CORP_API_VCODE', '') + +######################### +# Alliance Configuration +######################### +# ALLIANCE_ID - Set this to your Alliance ID (get this from https://zkillboard.com/alliance/#######) +# ALLIANCE_NAME - Set this to your Alliance Name +######################## +ALLIANCE_ID = os.environ.get('AA_ALLIANCE_ID', '12345') +ALLIANCE_NAME = os.environ.get('AA_ALLIANCE_NAME', 'Alliance Auth Test Alliance') + +######################## +# API Configuration +######################## +# MEMBER_API_MASK - Numeric value of minimum API mask required for members +# MEMBER_API_ACCOUNT - Require API to be for Account and not character restricted +# BLUE_API_MASK - Numeric value of minimum API mask required for blues +# BLUE_API_ACCOUNT - Require API to be for Account and not character restricted +# REJECT_OLD_APIS - Require each submitted API be newer than the latest submitted API +# REJECT_OLD_APIS_MARGIN - Margin from latest submitted API ID within which a newly submitted API is still accepted +# API_SSO_VALIDATION - Require users to prove ownership of newly entered API keys via SSO +# Requires SSO to be configured. +####################### +MEMBER_API_MASK = os.environ.get('AA_MEMBER_API_MASK', 268435455) +MEMBER_API_ACCOUNT = 'True' == os.environ.get('AA_MEMBER_API_ACCOUNT', 'True') +BLUE_API_MASK = os.environ.get('AA_BLUE_API_MASK', 8388608) +BLUE_API_ACCOUNT = 'True' == os.environ.get('AA_BLUE_API_ACCOUNT', 'True') +REJECT_OLD_APIS = 'True' == os.environ.get('AA_REJECT_OLD_APIS', 'False') +REJECT_OLD_APIS_MARGIN = os.environ.get('AA_REJECT_OLD_APIS_MARGIN', 50) +API_SSO_VALIDATION = 'True' == os.environ.get('AA_API_SSO_VALIDATION', 'False') + +####################### +# EVE Provider Settings +####################### +# EVEONLINE_CHARACTER_PROVIDER - Name of default data source for getting eve character data +# EVEONLINE_CORP_PROVIDER - Name of default data source for getting eve corporation data +# EVEONLINE_ALLIANCE_PROVIDER - Name of default data source for getting eve alliance data +# EVEONLINE_ITEMTYPE_PROVIDER - Name of default data source for getting eve item type data +# +# Available sources are 'esi' and 'xml'. Leaving blank results in the default 'esi' being used. +####################### +EVEONLINE_CHARACTER_PROVIDER = os.environ.get('AA_EVEONLINE_CHARACTER_PROVIDER', 'xml') +EVEONLINE_CORP_PROVIDER = os.environ.get('AA_EVEONLINE_CORP_PROVIDER', 'xml') +EVEONLINE_ALLIANCE_PROVIDER = os.environ.get('AA_EVEONLINE_ALLIANCE_PROVIDER', 'xml') +EVEONLINE_ITEMTYPE_PROVIDER = os.environ.get('AA_EVEONLINE_ITEMTYPE_PROVIDER', 'xml') + +##################### +# Alliance Market +##################### +MARKET_URL = os.environ.get('AA_MARKET_URL', 'http://yourdomain.com/market') + +##################### +# HR Configuration +##################### +# JACK_KNIFE_URL - Url for the audit page of API Jack knife +# Should seriously replace with your own. +##################### +JACK_KNIFE_URL = os.environ.get('AA_JACK_KNIFE_URL', 'http://example.com/eveapi/audit.php') + +##################### +# Forum Configuration +##################### +# IPBOARD_ENDPOINT - Api endpoint if using ipboard +# IPBOARD_APIKEY - Api key to interact with ipboard +# IPBOARD_APIMODULE - Module for alliance auth *leave alone* +##################### +IPBOARD_ENDPOINT = os.environ.get('AA_IPBOARD_ENDPOINT', 'example.com/interface/board/index.php') +IPBOARD_APIKEY = os.environ.get('AA_IPBOARD_APIKEY', 'somekeyhere') +IPBOARD_APIMODULE = 'aa' + +######################## +# XenForo Configuration +######################## +XENFORO_ENDPOINT = os.environ.get('AA_XENFORO_ENDPOINT', 'example.com/api.php') +XENFORO_DEFAULT_GROUP = os.environ.get('AA_XENFORO_DEFAULT_GROUP', 0) +XENFORO_APIKEY = os.environ.get('AA_XENFORO_APIKEY', 'yourapikey') +##################### + +###################### +# Jabber Configuration +###################### +# JABBER_URL - Jabber address url +# JABBER_PORT - Jabber service portal +# JABBER_SERVER - Jabber server url +# OPENFIRE_ADDRESS - Address of the openfire admin console including port +# Please use http with 9090 or https with 9091 +# OPENFIRE_SECRET_KEY - Openfire REST API secret key +# BROADCAST_USER - Broadcast user JID +# BROADCAST_USER_PASSWORD - Broadcast user password +###################### +JABBER_URL = os.environ.get('AA_JABBER_URL', "example.com") +JABBER_PORT = int(os.environ.get('AA_JABBER_PORT', '5223')) +JABBER_SERVER = os.environ.get('AA_JABBER_SERVER', "example.com") +OPENFIRE_ADDRESS = os.environ.get('AA_OPENFIRE_ADDRESS', "http://example.com:9090") +OPENFIRE_SECRET_KEY = os.environ.get('AA_OPENFIRE_SECRET_KEY', "somekey") +BROADCAST_USER = os.environ.get('AA_BROADCAST_USER', "broadcast@") + JABBER_URL +BROADCAST_USER_PASSWORD = os.environ.get('AA_BROADCAST_USER_PASSWORD', "somepassword") +BROADCAST_SERVICE_NAME = os.environ.get('AA_BROADCAST_SERVICE_NAME', "broadcast") + +###################################### +# Mumble Configuration +###################################### +# MUMBLE_URL - Mumble server url +# MUMBLE_SERVER_ID - Mumble server id +###################################### +MUMBLE_URL = os.environ.get('AA_MUMBLE_URL', "example.com") +MUMBLE_SERVER_ID = int(os.environ.get('AA_MUMBLE_SERVER_ID', '1')) + +###################################### +# PHPBB3 Configuration +###################################### + +###################################### +# Teamspeak3 Configuration +###################################### +# TEAMSPEAK3_SERVER_IP - Teamspeak3 server ip +# TEAMSPEAK3_SERVER_PORT - Teamspeak3 server port +# TEAMSPEAK3_SERVERQUERY_USER - Teamspeak3 serverquery username +# TEAMSPEAK3_SERVERQUERY_PASSWORD - Teamspeak3 serverquery password +# TEAMSPEAK3_VIRTUAL_SERVER - Virtual server id +# TEAMSPEAK3_AUTHED_GROUP_ID - Default authed group id +# TEAMSPEAK3_PUBLIC_URL - teamspeak3 public url used for link creation +###################################### +TEAMSPEAK3_SERVER_IP = os.environ.get('AA_TEAMSPEAK3_SERVER_IP', '127.0.0.1') +TEAMSPEAK3_SERVER_PORT = int(os.environ.get('AA_TEAMSPEAK3_SERVER_PORT', '10011')) +TEAMSPEAK3_SERVERQUERY_USER = os.environ.get('AA_TEAMSPEAK3_SERVERQUERY_USER', 'serveradmin') +TEAMSPEAK3_SERVERQUERY_PASSWORD = os.environ.get('AA_TEAMSPEAK3_SERVERQUERY_PASSWORD', 'passwordhere') +TEAMSPEAK3_VIRTUAL_SERVER = int(os.environ.get('AA_TEAMSPEAK3_VIRTUAL_SERVER', '1')) +TEAMSPEAK3_PUBLIC_URL = os.environ.get('AA_TEAMSPEAK3_PUBLIC_URL', 'example.com') + +###################################### +# Discord Configuration +###################################### +# DISCORD_GUILD_ID - ID of the guild to manage +# DISCORD_BOT_TOKEN - oauth token of the app bot user +# DISCORD_INVITE_CODE - invite code to the server +# DISCORD_APP_ID - oauth app client ID +# DISCORD_APP_SECRET - oauth app secret +# DISCORD_CALLBACK_URL - oauth callback url +# DISCORD_SYNC_NAMES - enable to force discord nicknames to be set to eve char name (bot needs Manage Nicknames permission) +###################################### +DISCORD_GUILD_ID = os.environ.get('AA_DISCORD_GUILD_ID', '') +DISCORD_BOT_TOKEN = os.environ.get('AA_DISCORD_BOT_TOKEN', '') +DISCORD_INVITE_CODE = os.environ.get('AA_DISCORD_INVITE_CODE', '') +DISCORD_APP_ID = os.environ.get('AA_DISCORD_APP_ID', '') +DISCORD_APP_SECRET = os.environ.get('AA_DISCORD_APP_SECRET', '') +DISCORD_CALLBACK_URL = os.environ.get('AA_DISCORD_CALLBACK_URL', 'http://example.com/discord_callback') +DISCORD_SYNC_NAMES = 'True' == os.environ.get('AA_DISCORD_SYNC_NAMES', 'False') + +###################################### +# Discourse Configuration +###################################### +# DISCOURSE_URL - Web address of the forums (no trailing slash) +# DISCOURSE_API_USERNAME - API account username +# DISCOURSE_API_KEY - API Key +# DISCOURSE_SSO_SECRET - SSO secret key +###################################### +DISCOURSE_URL = os.environ.get('AA_DISCOURSE_URL', 'https://example.com') +DISCOURSE_API_USERNAME = os.environ.get('AA_DISCOURSE_API_USERNAME', '') +DISCOURSE_API_KEY = os.environ.get('AA_DISCOURSE_API_KEY', '') +DISCOURSE_SSO_SECRET = 'd836444a9e4084d5b224a60c208dce14' +# Example secret from https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045 + +##################################### +# IPS4 Configuration +##################################### +# IPS4_URL - base url of the IPS4 install (no trailing slash) +# IPS4_API_KEY - API key provided by IPS4 +##################################### +IPS4_URL = os.environ.get('AA_IPS4_URL', 'http://example.com/ips4') +IPS4_API_KEY = os.environ.get('AA_IPS4_API_KEY', '') + + +###################################### +# SMF Configuration +###################################### +SMF_URL = os.environ.get('AA_SMF_URL', '') + +###################################### +# Fleet-Up Configuration +###################################### +# FLEETUP_APP_KEY - The app key from http://fleet-up.com/Api/MyApps +# FLEETUP_USER_ID - The user id from http://fleet-up.com/Api/MyKeys +# FLEETUP_API_ID - The API id from http://fleet-up.com/Api/MyKeys +# FLEETUP_GROUP_ID - The id of the group you want to pull data from, see http://fleet-up.com/Api/Endpoints#groups_mygroupmemberships +###################################### +FLEETUP_APP_KEY = os.environ.get('AA_FLEETUP_APP_KEY', '') +FLEETUP_USER_ID = os.environ.get('AA_FLEETUP_USER_ID', '') +FLEETUP_API_ID = os.environ.get('AA_FLEETUP_API_ID', '') +FLEETUP_GROUP_ID = os.environ.get('AA_FLEETUP_GROUP_ID', '') + +PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', +] + + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format' : "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s", + 'datefmt' : "%d/%b/%Y %H:%M:%S" + }, + 'simple': { + 'format': '%(levelname)s %(message)s' + }, + }, + 'handlers': { + 'console': { + 'level': 'DEBUG', # edit this line to change logging level to console + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, + 'notifications': { # creates notifications for users with logging_notifications permission + 'level': 'ERROR', # edit this line to change logging level to notifications + 'class': 'notifications.handlers.NotificationHandler', + 'formatter': 'verbose', + }, + }, + 'loggers': { + 'authentication': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'celerytask': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'eveonline': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'groupmanagement': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'hrapplications': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'portal': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'registration': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'services': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'srp': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'timerboard': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'sigtracker': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'optimer': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'corputils': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'fleetactivitytracking': { + 'handlers': ['console', 'notifications'], + 'level': 'ERROR', + }, + 'util': { + 'handlers': ['console', 'notifications'], + 'level': 'DEBUG', + }, + 'django': { + 'handlers': ['console', 'notifications'], + 'level': 'ERROR', + }, + } +} + +LOGGING = None # Comment out to enable logging for debugging diff --git a/alliance_auth/urls.py b/alliance_auth/urls.py index 48f137c4..b857d402 100755 --- a/alliance_auth/urls.py +++ b/alliance_auth/urls.py @@ -21,6 +21,8 @@ from alliance_auth import NAME admin.site.site_header = NAME +from alliance_auth.hooks import get_hooks + # Functional/Untranslated URL's urlpatterns = [ # Locale @@ -47,85 +49,6 @@ urlpatterns = [ name='auth_main_character_change'), url(r'^api_verify_owner/(\w+)/$', eveonline.views.api_sso_validate, name='auth_api_sso'), - # Forum Service Control - url(r'^activate_forum/$', services.views.activate_forum, name='auth_activate_forum'), - url(r'^deactivate_forum/$', services.views.deactivate_forum, name='auth_deactivate_forum'), - url(r'^reset_forum_password/$', services.views.reset_forum_password, - name='auth_reset_forum_password'), - url(r'^set_forum_password/$', services.views.set_forum_password, name='auth_set_forum_password'), - - # Jabber Service Control - url(r'^activate_jabber/$', services.views.activate_jabber, name='auth_activate_jabber'), - url(r'^deactivate_jabber/$', services.views.deactivate_jabber, name='auth_deactivate_jabber'), - url(r'^reset_jabber_password/$', services.views.reset_jabber_password, - name='auth_reset_jabber_password'), - - # Mumble service control - url(r'^activate_mumble/$', services.views.activate_mumble, name='auth_activate_mumble'), - url(r'^deactivate_mumble/$', services.views.deactivate_mumble, name='auth_deactivate_mumble'), - url(r'^reset_mumble_password/$', services.views.reset_mumble_password, - name='auth_reset_mumble_password'), - url(r'^set_mumble_password/$', services.views.set_mumble_password, name='auth_set_mumble_password'), - - # Ipboard service control - url(r'^activate_ipboard/$', services.views.activate_ipboard_forum, - name='auth_activate_ipboard'), - url(r'^deactivate_ipboard/$', services.views.deactivate_ipboard_forum, - name='auth_deactivate_ipboard'), - url(r'^reset_ipboard_password/$', services.views.reset_ipboard_password, - name='auth_reset_ipboard_password'), - url(r'^set_ipboard_password/$', services.views.set_ipboard_password, name='auth_set_ipboard_password'), - - # XenForo service control - url(r'^activate_xenforo/$', services.views.activate_xenforo_forum, - name='auth_activate_xenforo'), - url(r'^deactivate_xenforo/$', services.views.deactivate_xenforo_forum, - name='auth_deactivate_xenforo'), - url(r'^reset_xenforo_password/$', services.views.reset_xenforo_password, - name='auth_reset_xenforo_password'), - url(r'^set_xenforo_password/$', services.views.set_xenforo_password, name='auth_set_xenforo_password'), - - # Teamspeak3 service control - url(r'^activate_teamspeak3/$', services.views.activate_teamspeak3, - name='auth_activate_teamspeak3'), - url(r'^deactivate_teamspeak3/$', services.views.deactivate_teamspeak3, - name='auth_deactivate_teamspeak3'), - url(r'reset_teamspeak3_perm/$', services.views.reset_teamspeak3_perm, - name='auth_reset_teamspeak3_perm'), - - # Discord Service Control - url(r'^activate_discord/$', services.views.activate_discord, name='auth_activate_discord'), - url(r'^deactivate_discord/$', services.views.deactivate_discord, name='auth_deactivate_discord'), - url(r'^reset_discord/$', services.views.reset_discord, name='auth_reset_discord'), - url(r'^discord_callback/$', services.views.discord_callback, name='auth_discord_callback'), - url(r'^discord_add_bot/$', services.views.discord_add_bot, name='auth_discord_add_bot'), - - # Discourse Service Control - url(r'^discourse_sso$', services.views.discourse_sso, name='auth_discourse_sso'), - - # IPS4 Service Control - url(r'^activate_ips4/$', services.views.activate_ips4, - name='auth_activate_ips4'), - url(r'^deactivate_ips4/$', services.views.deactivate_ips4, - name='auth_deactivate_ips4'), - url(r'^reset_ips4_password/$', services.views.reset_ips4_password, - name='auth_reset_ips4_password'), - url(r'^set_ips4_password/$', services.views.set_ips4_password, name='auth_set_ips4_password'), - - # SMF Service Control - url(r'^activate_smf/$', services.views.activate_smf, name='auth_activate_smf'), - url(r'^deactivate_smf/$', services.views.deactivate_smf, name='auth_deactivate_smf'), - url(r'^reset_smf_password/$', services.views.reset_smf_password, - name='auth_reset_smf_password'), - url(r'^set_smf_password/$', services.views.set_smf_password, name='auth_set_smf_password'), - - # Alliance Market Control - url(r'^activate_market/$', services.views.activate_market, name='auth_activate_market'), - url(r'^deactivate_market/$', services.views.deactivate_market, name='auth_deactivate_market'), - url(r'^reset_market_password/$', services.views.reset_market_password, - name='auth_reset_market_password'), - url(r'^set_market_password/$', services.views.set_market_password, name='auth_set_market_password'), - # SRP URLS url(r'^srp_fleet_remove/(\w+)$', srp.views.srp_fleet_remove, name='auth_srp_fleet_remove'), url(r'^srp_fleet_disable/(\w+)$', srp.views.srp_fleet_disable, name='auth_srp_fleet_disable'), @@ -241,11 +164,6 @@ urlpatterns += i18n_patterns( # Service Urls url(_(r'^services/$'), services.views.services_view, name='auth_services'), - url(_(r'^services/jabber_broadcast/$'), services.views.jabber_broadcast_view, - name='auth_jabber_broadcast_view'), - - # Teamspeak Urls - url(r'verify_teamspeak3/$', services.views.verify_teamspeak3, name='auth_verify_teamspeak3'), # Timer URLS url(_(r'^timers/$'), timerboard.views.timer_view, name='auth_timer_view'), @@ -271,9 +189,6 @@ urlpatterns += i18n_patterns( url(_(r'^notifications/$'), notifications.views.notification_list, name='auth_notification_list'), url(_(r'^notifications/(\w+)/$'), notifications.views.notification_view, name='auth_notification_view'), - # Jabber - url(_(r'^set_jabber_password/$'), services.views.set_jabber_password, name='auth_set_jabber_password'), - # FleetActivityTracking (FAT) url(r'^fat/$', fleetactivitytracking.views.fatlink_view, name='auth_fatlink_view'), url(r'^fat/statistics/$', fleetactivitytracking.views.fatlink_statistics_view, name='auth_fatlink_view_statistics'), @@ -297,3 +212,9 @@ urlpatterns += i18n_patterns( url(r'^fat/link/(?P[a-zA-Z0-9]+)/(?P[a-z0-9_-]+)/$', fleetactivitytracking.views.click_fatlink_view), ) + +# Append hooked service urls +services = get_hooks('services_hook') +for svc in services: + urlpatterns += svc().urlpatterns + diff --git a/authentication/admin.py b/authentication/admin.py index 22860d17..06b255ff 100644 --- a/authentication/admin.py +++ b/authentication/admin.py @@ -1,22 +1,19 @@ from __future__ import unicode_literals from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.models import User +from django.utils.text import slugify from authentication.models import AuthServicesInfo from eveonline.models import EveCharacter -from services.tasks import update_jabber_groups -from services.tasks import update_mumble_groups -from services.tasks import update_forum_groups -from services.tasks import update_ipboard_groups -from services.tasks import update_smf_groups -from services.tasks import update_teamspeak3_groups -from services.tasks import update_discord_groups -from services.tasks import update_discord_nickname -from services.tasks import update_discourse_groups +from alliance_auth.hooks import get_hooks +from services.hooks import ServicesHook @admin.register(AuthServicesInfo) class AuthServicesInfoManager(admin.ModelAdmin): + @staticmethod def main_character(obj): if obj.main_char_id: @@ -34,120 +31,69 @@ class AuthServicesInfoManager(admin.ModelAdmin): def has_add_permission(request, obj=None): return False - def sync_jabber(self, request, queryset): - count = 0 - for a in queryset: # queryset filtering doesn't work here? - if a.jabber_username != "": - update_jabber_groups.delay(a.user.pk) - count += 1 - self.message_user(request, "%s jabber accounts queued for group sync." % count) - - sync_jabber.short_description = "Sync groups for selected jabber accounts" - - def sync_mumble(self, request, queryset): - count = 0 - for a in queryset: - if a.mumble_username != "": - update_mumble_groups.delay(a.user.pk) - count += 1 - self.message_user(request, "%s mumble accounts queued for group sync." % count) - - sync_mumble.short_description = "Sync groups for selected mumble accounts" - - def sync_forum(self, request, queryset): - count = 0 - for a in queryset: - if a.forum_username != "": - update_forum_groups.delay(a.user.pk) - count += 1 - self.message_user(request, "%s forum accounts queued for group sync." % count) - - sync_forum.short_description = "Sync groups for selected forum accounts" - - def sync_ipboard(self, request, queryset): - count = 0 - for a in queryset: - if a.ipboard_username != "": - update_ipboard_groups.delay(a.user.pk) - count += 1 - self.message_user(request, "%s ipboard accounts queued for group sync." % count) - - sync_ipboard.short_description = "Sync groups for selected ipboard accounts" - - def sync_smf(self, request, queryset): - count = 0 - for a in queryset: - if a.smf_username != "": - update_smf_groups.delay(a.user.pk) - count += 1 - self.message_user(request, "%s smf accounts queued for group sync." % count) - - sync_smf.short_description = "Sync groups for selected smf accounts" - - def sync_teamspeak(self, request, queryset): - count = 0 - for a in queryset: - if a.teamspeak3_uid != "": - update_teamspeak3_groups.delay(a.user.pk) - count += 1 - self.message_user(request, "%s teamspeak accounts queued for group sync." % count) - - sync_teamspeak.short_description = "Sync groups for selected teamspeak accounts" - - def sync_discord(self, request, queryset): - count = 0 - for a in queryset: - if a.discord_uid != "": - update_discord_groups.delay(a.user.pk) - count += 1 - self.message_user(request, "%s discord accounts queued for group sync." % count) - - sync_discord.short_description = "Sync groups for selected discord accounts" - - def sync_discourse(self, request, queryset): - count = 0 - for a in queryset: - if a.discourse_enabled: - update_discourse_groups.delay(a.user.pk) - count += 1 - self.message_user(request, "%s discourse accounts queued for group sync." % count) - - sync_discourse.short_description = "Sync groups for selected discourse accounts" - - def sync_nicknames(self, request, queryset): - count = 0 - for a in queryset: - if a.discord_uid != "": - update_discord_nickname(a.user.pk) - count += 1 - self.message_user(request, "%s discord accounts queued for nickname sync." % count) - - sync_nicknames.short_description = "Sync nicknames for selected discord accounts" - - actions = [ - 'sync_jabber', - 'sync_mumble', - 'sync_forum', - 'sync_ipboard', - 'sync_smf', - 'sync_teamspeak', - 'sync_discord', - 'sync_discourse', - 'sync_nicknames', - ] - search_fields = [ 'user__username', - 'ipboard_username', - 'xenforo_username', - 'forum_username', - 'jabber_username', - 'mumble_username', - 'teamspeak3_uid', - 'discord_uid', - 'ips4_username', - 'smf_username', - 'market_username', 'main_char_id', ] list_display = ('user', 'main_character') + + +def make_service_hooks_update_groups_action(service): + """ + Make a admin action for the given service + :param service: services.hooks.ServicesHook + :return: fn to update services groups for the selected users + """ + def update_service_groups(modeladmin, request, queryset): + for user in queryset: # queryset filtering doesn't work here? + service.update_groups(user) + + update_service_groups.__name__ = str('update_{}_groups'.format(slugify(service.name))) + update_service_groups.short_description = "Sync groups for selected {} accounts".format(service.title) + return update_service_groups + + +def make_service_hooks_sync_nickname_action(service): + """ + Make a sync_nickname admin action for the given service + :param service: services.hooks.ServicesHook + :return: fn to sync nickname for the selected users + """ + def sync_nickname(modeladmin, request, queryset): + for user in queryset: # queryset filtering doesn't work here? + service.sync_nickname(user) + + sync_nickname.__name__ = str('sync_{}_nickname'.format(slugify(service.name))) + sync_nickname.short_description = "Sync nicknames for selected {} accounts".format(service.title) + return sync_nickname + + +class UserAdmin(BaseUserAdmin): + """ + Extending Django's UserAdmin model + """ + def get_actions(self, request): + actions = super(BaseUserAdmin, self).get_actions(request) + + for hook in get_hooks('services_hook'): + svc = hook() + # Check update_groups is redefined/overloaded + if svc.update_groups.__module__ != ServicesHook.update_groups.__module__: + action = make_service_hooks_update_groups_action(svc) + actions[action.__name__] = (action, + action.__name__, + action.short_description) + # Create sync nickname action if service implements it + if svc.sync_nickname.__module__ != ServicesHook.sync_nickname.__module__: + action = make_service_hooks_sync_nickname_action(svc) + actions[action.__name__] = (action, + action.__name__, + action.short_description) + + return actions + +# Re-register UserAdmin +try: + admin.site.unregister(User) +finally: + admin.site.register(User, UserAdmin) diff --git a/authentication/managers.py b/authentication/managers.py index f9b2ef7b..4bfa0a39 100755 --- a/authentication/managers.py +++ b/authentication/managers.py @@ -24,73 +24,6 @@ class AuthServicesInfoManager: else: logger.error("Failed to update user %s main character id to %s: user does not exist." % (user, char_id)) - @staticmethod - def update_user_forum_info(username, user): - if User.objects.filter(username=user.username).exists(): - logger.debug("Updating user %s forum info: username %s" % (user, username)) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - authserviceinfo.forum_username = username - authserviceinfo.save(update_fields=['forum_username']) - logger.info("Updated user %s forum info in authservicesinfo model." % user) - else: - logger.error("Failed to update user %s forum info: user does not exist." % user) - - @staticmethod - def update_user_jabber_info(username, user): - if User.objects.filter(username=user.username).exists(): - logger.debug("Updating user %s jabber info: username %s" % (user, username)) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - authserviceinfo.jabber_username = username - authserviceinfo.save(update_fields=['jabber_username']) - logger.info("Updated user %s jabber info in authservicesinfo model." % user) - else: - logger.error("Failed to update user %s jabber info: user does not exist." % user) - - @staticmethod - def update_user_mumble_info(username, user): - if User.objects.filter(username=user.username).exists(): - logger.debug("Updating user %s mumble info: username %s" % (user, username)) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - authserviceinfo.mumble_username = username - authserviceinfo.save(update_fields=['mumble_username']) - logger.info("Updated user %s mumble info in authservicesinfo model." % user) - else: - logger.error("Failed to update user %s mumble info: user does not exist." % user) - - @staticmethod - def update_user_ipboard_info(username, user): - if User.objects.filter(username=user.username).exists(): - logger.debug("Updating user %s ipboard info: uername %s" % (user, username)) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - authserviceinfo.ipboard_username = username - authserviceinfo.save(update_fields=['ipboard_username']) - logger.info("Updated user %s ipboard info in authservicesinfo model." % user) - else: - logger.error("Failed to update user %s ipboard info: user does not exist." % user) - - @staticmethod - def update_user_xenforo_info(username, user): - if User.objects.filter(username=user.username).exists(): - logger.debug("Updating user %s xenforo info: uername %s" % (user, username)) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - authserviceinfo.xenforo_username = username - authserviceinfo.save(update_fields=['xenforo_username']) - logger.info("Updated user %s xenforo info in authservicesinfo model." % user) - else: - logger.error("Failed to update user %s xenforo info: user does not exist." % user) - - @staticmethod - def update_user_teamspeak3_info(uid, perm_key, user): - if User.objects.filter(username=user.username).exists(): - logger.debug("Updating user %s teamspeak3 info: uid %s" % (user, uid)) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - authserviceinfo.teamspeak3_uid = uid - authserviceinfo.teamspeak3_perm_key = perm_key - authserviceinfo.save(update_fields=['teamspeak3_uid', 'teamspeak3_perm_key']) - logger.info("Updated user %s teamspeak3 info in authservicesinfo model." % user) - else: - logger.error("Failed to update user %s teamspeak3 info: user does not exist." % user) - @staticmethod def update_is_blue(is_blue, user): if User.objects.filter(username=user.username).exists(): @@ -100,51 +33,6 @@ class AuthServicesInfoManager: authserviceinfo.save(update_fields=['is_blue']) logger.info("Updated user %s blue status to %s in authservicesinfo model." % (user, is_blue)) - @staticmethod - def update_user_discord_info(user_id, user): - if User.objects.filter(username=user.username).exists(): - logger.debug("Updating user %s discord info: user_id %s" % (user, user_id)) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - authserviceinfo.discord_uid = user_id - authserviceinfo.save(update_fields=['discord_uid']) - logger.info("Updated user %s discord info in authservicesinfo model." % user) - else: - logger.error("Failed to update user %s discord info: user does not exist." % user) - - @staticmethod - def update_user_ips4_info(username, id, user): - if User.objects.filter(username=user.username).exists(): - logger.debug("Updating user %s IPS4 info: username %s" % (user, username)) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - authserviceinfo.ips4_username = username - authserviceinfo.ips4_id = id - authserviceinfo.save(update_fields=['ips4_username', 'ips4_id']) - logger.info("Updated user %s IPS4 info in authservicesinfo model." % user) - else: - logger.error("Failed to update user %s IPS4 info: user does not exist." % user) - - @staticmethod - def update_user_smf_info(username, user): - if User.objects.filter(username=user.username).exists(): - logger.debug("Updating user %s forum info: username %s" % (user, username)) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - authserviceinfo.smf_username = username - authserviceinfo.save(update_fields=['smf_username']) - logger.info("Updated user %s smf info in authservicesinfo model." % user) - else: - logger.error("Failed to update user %s smf info: user does not exist." % user) - - @staticmethod - def update_user_market_info(username, user): - if User.objects.filter(username=user.username).exists(): - logger.debug("Updating user %s market info: username %s" % (user, username)) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - authserviceinfo.market_username = username - authserviceinfo.save(update_fields=['market_username']) - logger.info("Updated user %s market info in authservicesinfo model." % user) - else: - logger.error("Failed to update user %s market info: user does not exist." % user) - class UserState: def __init__(self): diff --git a/authentication/migrations/0013_service_modules.py b/authentication/migrations/0013_service_modules.py new file mode 100644 index 00000000..11f2c613 --- /dev/null +++ b/authentication/migrations/0013_service_modules.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-11 23:14 +from __future__ import unicode_literals + +from django.db import migrations +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist + +import logging + +logger = logging.getLogger(__name__) + + +def optional_dependencies(): + """ + Only require these migrations if the given app + is installed. If the app isn't installed then + the relevant AuthServicesInfo field will be LOST + when the data migration is run. + """ + installed_apps = settings.INSTALLED_APPS + + dependencies = [] + + if 'services.modules.xenforo' in installed_apps: + dependencies.append(('xenforo', '0001_initial')) + if 'services.modules.discord' in installed_apps: + dependencies.append(('discord', '0001_initial')) + if 'services.modules.discourse' in installed_apps: + dependencies.append(('discourse', '0001_initial')) + if 'services.modules.ipboard' in installed_apps: + dependencies.append(('ipboard', '0001_initial')) + if 'services.modules.ips4' in installed_apps: + dependencies.append(('ips4', '0001_initial')) + if 'services.modules.market' in installed_apps: + dependencies.append(('market', '0001_initial')) + if 'services.modules.openfire' in installed_apps: + dependencies.append(('openfire', '0001_initial')) + if 'services.modules.smf' in installed_apps: + dependencies.append(('smf', '0001_initial')) + if 'services.modules.teamspeak3' in installed_apps: + dependencies.append(('teamspeak3', '0003_teamspeak3user')) + if 'services.modules.mumble' in installed_apps: + dependencies.append(('mumble', '0003_mumbleuser_user')) + if 'services.modules.phpbb3' in installed_apps: + dependencies.append(('phpbb3', '0001_initial')) + + return dependencies + + +def forward(apps, schema_editor): + installed_apps = settings.INSTALLED_APPS + AuthServicesInfo = apps.get_model("authentication", "AuthServicesInfo") + + XenforoUser = apps.get_model('xenforo', 'XenforoUser') if 'services.modules.xenforo' in installed_apps else None + DiscordUser = apps.get_model('discord', 'DiscordUser') if 'services.modules.discord' in installed_apps else None + DiscourseUser = apps.get_model('discourse', 'DiscourseUser') if 'services.modules.discourse' in installed_apps else None + IpboardUser = apps.get_model('ipboard', 'IpboardUser') if 'services.modules.ipboard' in installed_apps else None + Ips4User = apps.get_model('ips4', 'Ips4User') if 'services.modules.ips4' in installed_apps else None + MarketUser = apps.get_model('market', 'MarketUser') if 'services.modules.market' in installed_apps else None + OpenfireUser = apps.get_model('openfire', 'OpenfireUser') if 'services.modules.openfire' in installed_apps else None + SmfUser = apps.get_model('smf', 'SmfUser') if 'services.modules.smf' in installed_apps else None + Teamspeak3User = apps.get_model('teamspeak3', 'Teamspeak3User') if 'services.modules.teamspeak3' in installed_apps else None + MumbleUser = apps.get_model('mumble', 'MumbleUser') if 'services.modules.mumble' in installed_apps else None + Phpbb3User = apps.get_model('phpbb3', 'Phpbb3User') if 'services.modules.phpbb3' in installed_apps else None + + for authinfo in AuthServicesInfo.objects.all(): + user = authinfo.user + + if XenforoUser is not None and authinfo.xenforo_username: + logging.debug('Updating Xenforo info for %s' % user.username) + xfu = XenforoUser() + xfu.user = user + xfu.username = authinfo.xenforo_username + xfu.save() + + if DiscordUser is not None and authinfo.discord_uid: + logging.debug('Updating Discord info for %s' % user.username) + du = DiscordUser() + du.user = user + du.uid = authinfo.discord_uid + du.save() + + if DiscourseUser is not None and authinfo.discourse_enabled: + logging.debug('Updating Discourse info for %s' % user.username) + du = DiscourseUser() + du.user = user + du.enabled = authinfo.discourse_enabled + du.save() + + if IpboardUser is not None and authinfo.ipboard_username: + logging.debug('Updating IPBoard info for %s' % user.username) + ipb = IpboardUser() + ipb.user = user + ipb.username = authinfo.ipboard_username + ipb.save() + + if Ips4User is not None and authinfo.ips4_id: + logging.debug('Updating Ips4 info for %s' % user.username) + ips = Ips4User() + ips.user = user + ips.id = authinfo.ips4_id + ips.username = authinfo.ips4_username + ips.save() + + if MarketUser is not None and authinfo.market_username: + logging.debug('Updating Market info for %s' % user.username) + mkt = MarketUser() + mkt.user = user + mkt.username = authinfo.market_username + mkt.save() + + if OpenfireUser is not None and authinfo.jabber_username: + logging.debug('Updating Openfire (jabber) info for %s' % user.username) + ofu = OpenfireUser() + ofu.user = user + ofu.username = authinfo.jabber_username + ofu.save() + + if SmfUser is not None and authinfo.smf_username: + logging.debug('Updating SMF info for %s' % user.username) + smf = SmfUser() + smf.user = user + smf.username = authinfo.smf_username + smf.save() + + if Teamspeak3User is not None and authinfo.teamspeak3_uid: + logging.debug('Updating Teamspeak3 info for %s' % user.username) + ts3 = Teamspeak3User() + ts3.user = user + ts3.uid = authinfo.teamspeak3_uid + ts3.perm_key = authinfo.teamspeak3_perm_key + ts3.save() + + if MumbleUser is not None and authinfo.mumble_username: + logging.debug('Updating mumble info for %s' % user.username) + try: + mbl = MumbleUser.objects.get(username=authinfo.mumble_username) + mbl.user = user + mbl.save() + except ObjectDoesNotExist: + logger.warn('AuthServiceInfo mumble_username for {} but no ' + 'corresponding record in MumbleUser, dropping'.format(user.username)) + + if Phpbb3User is not None and authinfo.forum_username: + logging.debug('Updating phpbb3 info for %s' % user.username) + phb = Phpbb3User() + phb.user = user + phb.username = authinfo.forum_username + phb.save() + + +def reverse(apps, schema_editor): + User = apps.get_model('auth', 'User') + AuthServicesInfo = apps.get_model("authentication", "AuthServicesInfo") + + for user in User.objects.all(): + authinfo, c = AuthServicesInfo.objects.get_or_create(user=user) + + if hasattr(user, 'xenforo'): + logging.debug('Reversing xenforo for %s' % user.username) + authinfo.xenforo_username = user.xenforo.username + + if hasattr(user, 'discord'): + logging.debug('Reversing discord for %s' % user.username) + authinfo.discord_uid = user.discord.uid + + if hasattr(user, 'discourse'): + logging.debug('Reversing discourse for %s' % user.username) + authinfo.discourse_enabled = user.discourse.enabled + + if hasattr(user, 'ipboard'): + logging.debug('Reversing ipboard for %s' % user.username) + authinfo.ipboard_username = user.ipboard.username + + if hasattr(user, 'ips4'): + logging.debug('Reversing ips4 for %s' % user.username) + authinfo.ips4_id = user.ips4.id + authinfo.ips4_username = user.ips4.username + + if hasattr(user, 'market'): + logging.debug('Reversing market for %s' % user.username) + authinfo.market_username = user.market.username + + if hasattr(user, 'openfire'): + logging.debug('Reversing openfire (jabber) for %s' % user.username) + authinfo.jabber_username = user.openfire.username + + if hasattr(user, 'smf'): + logging.debug('Reversing smf for %s' % user.username) + authinfo.smf_username = user.smf.username + + if hasattr(user, 'teamspeak3'): + logging.debug('Reversing teamspeak3 for %s' % user.username) + authinfo.teamspeak3_uid = user.teamspeak3.uid + authinfo.teamspeak3_perm_key = user.teamspeak3.perm_key + + if hasattr(user, 'mumble'): + logging.debug('Reversing mumble for %s' % user.username) + try: + authinfo.mumble_username = user.mumble.all()[:1].get().username + except ObjectDoesNotExist: + logging.debug('Failed to reverse mumble for %s' % user.username) + + if hasattr(user, 'phpbb3'): + logging.debug('Reversing phpbb3 for %s' % user.username) + authinfo.forum_username = user.phpbb3.username + + logging.debug('Saving AuthServicesInfo for %s ' % user.username) + authinfo.save() + + +class Migration(migrations.Migration): + + dependencies = optional_dependencies() + [ + ('authentication', '0012_remove_add_delete_authservicesinfo_permissions'), + ] + + operations = [ + # Migrate data + migrations.RunPython(forward, reverse), + # Remove fields + migrations.RemoveField( + model_name='authservicesinfo', + name='discord_uid', + ), + migrations.RemoveField( + model_name='authservicesinfo', + name='discourse_enabled', + ), + migrations.RemoveField( + model_name='authservicesinfo', + name='forum_username', + ), + migrations.RemoveField( + model_name='authservicesinfo', + name='ipboard_username', + ), + migrations.RemoveField( + model_name='authservicesinfo', + name='ips4_id', + ), + migrations.RemoveField( + model_name='authservicesinfo', + name='ips4_username', + ), + migrations.RemoveField( + model_name='authservicesinfo', + name='jabber_username', + ), + migrations.RemoveField( + model_name='authservicesinfo', + name='market_username', + ), + migrations.RemoveField( + model_name='authservicesinfo', + name='mumble_username', + ), + migrations.RemoveField( + model_name='authservicesinfo', + name='smf_username', + ), + migrations.RemoveField( + model_name='authservicesinfo', + name='teamspeak3_perm_key', + ), + migrations.RemoveField( + model_name='authservicesinfo', + name='teamspeak3_uid', + ), + migrations.RemoveField( + model_name='authservicesinfo', + name='xenforo_username', + ), + ] diff --git a/authentication/models.py b/authentication/models.py index 024c037c..98c398de 100755 --- a/authentication/models.py +++ b/authentication/models.py @@ -16,19 +16,6 @@ class AuthServicesInfo(models.Model): (MEMBER_STATE, 'Member'), ) - ipboard_username = models.CharField(max_length=254, blank=True, default="") - xenforo_username = models.CharField(max_length=254, blank=True, default="") - forum_username = models.CharField(max_length=254, blank=True, default="") - jabber_username = models.CharField(max_length=254, blank=True, default="") - mumble_username = models.CharField(max_length=254, blank=True, default="") - teamspeak3_uid = models.CharField(max_length=254, blank=True, default="") - teamspeak3_perm_key = models.CharField(max_length=254, blank=True, default="") - discord_uid = models.CharField(max_length=254, blank=True, default="") - discourse_enabled = models.BooleanField(default=False, blank=True) - ips4_username = models.CharField(max_length=254, blank=True, default="") - ips4_id = models.CharField(max_length=254, blank=True, default="") - smf_username = models.CharField(max_length=254, blank=True, default="") - market_username = models.CharField(max_length=254, blank=True, default="") main_char_id = models.CharField(max_length=64, blank=True, default="") user = models.OneToOneField(User) state = models.CharField(blank=True, null=True, choices=STATE_CHOICES, default=NONE_STATE, max_length=10) diff --git a/docs/installation/services/mumble.md b/docs/installation/services/mumble.md index 7cc494c0..4e96c217 100644 --- a/docs/installation/services/mumble.md +++ b/docs/installation/services/mumble.md @@ -11,6 +11,10 @@ The mumble server package can be retrieved from a repository we need to add, mum Now two packages need to be installed: sudo apt-get install python-software-properties mumble-server + +You will also need to install the python dependencies for the authenticator script: + + pip install -r thirdparty/Mumble/requirements.txt ## Configuring Mumble Mumble ships with a configuration file that needs customization. By default it’s located at /etc/mumble-server.ini. Open it with your favourite text editor: diff --git a/eveonline/managers.py b/eveonline/managers.py index 013fecca..32af3312 100644 --- a/eveonline/managers.py +++ b/eveonline/managers.py @@ -3,6 +3,7 @@ from eveonline.models import EveCharacter from eveonline.models import EveApiKeyPair from eveonline.models import EveAllianceInfo from eveonline.models import EveCorporationInfo +from authentication.models import AuthServicesInfo from eveonline.providers import eve_adapter_factory, EveXmlProvider from services.managers.eve_api_manager import EveApiManager import logging @@ -241,6 +242,16 @@ class EveManager(object): return None + @staticmethod + def get_main_character(user): + """ + Get a characters main + :param user: django.contrib.auth.models.User + :return: EveCharacter + """ + authserviceinfo = AuthServicesInfo.objects.get(user=user) + return EveManager.get_character_by_id(authserviceinfo.main_char_id) + @staticmethod def get_characters_by_api_id(api_id): return EveCharacter.objects.filter(api_id=api_id) diff --git a/services/managers/fleetup_manager.py b/fleetup/managers.py similarity index 100% rename from services/managers/fleetup_manager.py rename to fleetup/managers.py diff --git a/fleetup/views.py b/fleetup/views.py index b92fe451..fbd832d6 100755 --- a/fleetup/views.py +++ b/fleetup/views.py @@ -4,7 +4,7 @@ from django.shortcuts import render from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import permission_required from django.template.defaulttags import register -from services.managers.fleetup_manager import FleetUpManager +from fleetup.managers import FleetUpManager from authentication.decorators import members_and_blues import logging diff --git a/requirements.txt b/requirements.txt index 1aaf75f3..5ccdd5a0 100755 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,6 @@ dnspython passlib requests>=2.9.1 bcrypt -zeroc-ice slugify requests-oauthlib sleekxmpp diff --git a/runtests.py b/runtests.py new file mode 100644 index 00000000..4088735c --- /dev/null +++ b/runtests.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python +from __future__ import unicode_literals +import os +import sys + +if __name__ == "__main__": + os.environ['DJANGO_SETTINGS_MODULE'] = 'alliance_auth.tests.test_settings' + + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv.insert(1, 'test')) diff --git a/services/admin.py b/services/admin.py index cfb11651..5da65126 100644 --- a/services/admin.py +++ b/services/admin.py @@ -1,17 +1,5 @@ from __future__ import unicode_literals from django.contrib import admin -from services.models import AuthTS -from services.models import MumbleUser from services.models import GroupCache - -class AuthTSgroupAdmin(admin.ModelAdmin): - fields = ['auth_group', 'ts_group'] - filter_horizontal = ('ts_group',) - - -admin.site.register(AuthTS, AuthTSgroupAdmin) - -admin.site.register(MumbleUser) - admin.site.register(GroupCache) diff --git a/services/forms.py b/services/forms.py index 4d97c738..c892563c 100644 --- a/services/forms.py +++ b/services/forms.py @@ -1,14 +1,9 @@ from __future__ import unicode_literals + from django import forms -from services.managers.teamspeak3_manager import Teamspeak3Manager from django.utils.translation import ugettext_lazy as _ -class JabberBroadcastForm(forms.Form): - group = forms.ChoiceField(label=_('Group'), widget=forms.Select) - message = forms.CharField(label=_('Message'), widget=forms.Textarea) - - class FleetFormatterForm(forms.Form): fleet_name = forms.CharField(label=_('Name of Fleet:'), required=True) fleet_commander = forms.CharField(label=_('Fleet Commander:'), required=True) @@ -26,12 +21,6 @@ class FleetFormatterForm(forms.Form): comments = forms.CharField(label=_('Comments'), widget=forms.Textarea, required=False) -class DiscordForm(forms.Form): - email = forms.CharField(label=_("Email Address"), required=True) - password = forms.CharField(label=_("Password"), required=True, widget=forms.PasswordInput) - update_avatar = forms.BooleanField(label=_("Update Avatar"), required=False, initial=True) - - class ServicePasswordForm(forms.Form): password = forms.CharField(label=_("Password"), required=True) @@ -40,12 +29,3 @@ class ServicePasswordForm(forms.Form): if not len(password) >= 8: raise forms.ValidationError(_("Password must be at least 8 characters long.")) return password - - -class TeamspeakJoinForm(forms.Form): - username = forms.CharField(widget=forms.HiddenInput()) - - def clean(self): - if Teamspeak3Manager._get_userid(self.cleaned_data['username']): - return self.cleaned_data - raise forms.ValidationError(_("Unable to locate user %s on server") % self.cleaned_data['username']) diff --git a/services/hooks.py b/services/hooks.py new file mode 100644 index 00000000..921cb3ab --- /dev/null +++ b/services/hooks.py @@ -0,0 +1,134 @@ +from __future__ import unicode_literals + +from django.template.loader import render_to_string +from django.utils.safestring import mark_safe + +from authentication.states import MEMBER_STATE, BLUE_STATE +from authentication.models import AuthServicesInfo + + +class ServicesHook: + """ + Abstract base class for creating a compatible services + hook. Decorate with @register('services_hook') to have the + services module registered for callbacks. Must be in + auth_hook(.py) sub module + """ + def __init__(self): + self.name = 'Undefined' + self.urlpatterns = [] + self.service_ctrl_template = 'registered/services_ctrl.html' + + @property + def title(self): + """ + A nicely formatted title of the service, for client facing + display. + :return: str + """ + return self.name.title() + + def delete_user(self, user, notify_user=False): + """ + Delete the users service account, optionally notify them + that the service has been disabled + :param user: Django.contrib.auth.models.User + :param notify_user: Whether the service should sent a + notification to the user about the disabling of their + service account. + :return: True if the service account has been disabled, + or False if it doesnt exist. + """ + pass + + def validate_user(self, user): + pass + + def sync_nickname(self, user): + """ + Sync the users nickname + :param user: Django.contrib.auth.models.User + :return: None + """ + pass + + def update_groups(self, user): + """ + Update the users group membership + :param user: Django.contrib.auth.models.User + :return: None + """ + pass + + def update_all_groups(self): + """ + Iterate through and update all users groups + :return: None + """ + pass + + def service_enabled_members(self): + """ + Return setting config for service enabled for members + :return: bool True if enabled + """ + return False + + def service_enabled_blues(self): + """ + Return setting config for service enabled for Blues + :return: bool True if enabled + """ + return False + + def service_active_for_user(self, user): + state = AuthServicesInfo.objects.get(user=user).state + return ( + (state == MEMBER_STATE and self.service_enabled_members()) or + (state == BLUE_STATE and self.service_enabled_blues()) + ) + + def show_service_ctrl(self, user, state): + """ + Whether the service control should be displayed to the given user + who has the given service state. Usually this function wont + require overloading. + :param user: django.contrib.auth.models.User + :param state: auth user state + :return: bool True if the service should be shown + """ + return (self.service_enabled_members() and ( + state == MEMBER_STATE or user.is_superuser)) or ( + self.service_enabled_blues() and (state == BLUE_STATE or user.is_superuser)) + + def render_services_ctrl(self, request): + """ + Render the services control template row + :param request: + :return: + """ + return '' + + def __str__(self): + return self.name or 'Unknown Service Module' + + class Urls: + def __init__(self): + self.auth_activate = '' + self.auth_set_password = '' + self.auth_reset_password = '' + self.auth_deactivate = '' + + +class MenuItemHook: + def __init__(self, text, classes, url_name, order=None): + self.text = text + self.classes = classes + self.url_name = url_name + self.template = 'public/menuitem.html' + self.order = order if order is not None else 9999 + + def render(self, request): + return render_to_string(self.template, + {'item': self}, + request=request) diff --git a/services/managers/mumble_manager.py b/services/managers/mumble_manager.py deleted file mode 100755 index 8c56d44d..00000000 --- a/services/managers/mumble_manager.py +++ /dev/null @@ -1,121 +0,0 @@ -from __future__ import unicode_literals -import os -import hashlib - -from services.models import MumbleUser - -import logging - -logger = logging.getLogger(__name__) - - -class MumbleManager: - def __init__(self): - pass - - @staticmethod - def __santatize_username(username): - sanatized = username.replace(" ", "_") - return sanatized - - @staticmethod - def __generate_random_pass(): - return os.urandom(8).encode('hex') - - @staticmethod - def __generate_username(username, corp_ticker): - return "[" + corp_ticker + "]" + username - - @staticmethod - def __generate_username_blue(username, corp_ticker): - return "[BLUE][" + corp_ticker + "]" + username - - @staticmethod - def _gen_pwhash(password): - return hashlib.sha1(password).hexdigest() - - @staticmethod - def create_user(corp_ticker, username): - logger.debug("Creating mumble user with username %s and ticker %s" % (username, corp_ticker)) - username_clean = MumbleManager.__santatize_username(MumbleManager.__generate_username(username, corp_ticker)) - password = MumbleManager.__generate_random_pass() - pwhash = MumbleManager._gen_pwhash(password) - logger.debug("Proceeding with mumble user creation: clean username %s, pwhash starts with %s" % ( - username_clean, pwhash[0:5])) - if MumbleUser.objects.filter(username=username_clean).exists() is False: - logger.info("Creating mumble user %s" % username_clean) - MumbleUser.objects.create(username=username_clean, pwhash=pwhash) - return username_clean, password - else: - logger.warn("Mumble user %s already exists. Updating password") - model = MumbleUser.objects.get(username=username_clean) - model.pwhash = pwhash - model.save() - logger.info("Updated mumble user %s" % username_clean) - return username_clean, password - - @staticmethod - def create_blue_user(corp_ticker, username): - logger.debug("Creating mumble blue user with username %s and ticker %s" % (username, corp_ticker)) - username_clean = MumbleManager.__santatize_username( - MumbleManager.__generate_username_blue(username, corp_ticker)) - password = MumbleManager.__generate_random_pass() - pwhash = MumbleManager._gen_pwhash(password) - logger.debug("Proceeding with mumble user creation: clean username %s, pwhash starts with %s" % ( - username_clean, pwhash[0:5])) - if MumbleUser.objects.filter(username=username_clean).exists() is False: - logger.info("Creating mumble user %s" % username_clean) - MumbleUser.objects.create(username=username_clean, pwhash=pwhash) - return username_clean, password - else: - logger.warn("Mumble user %s already exists. Updating password") - model = MumbleUser.objects.get(username=username_clean) - model.pwhash = pwhash - model.save() - logger.info("Updated mumble user %s" % username_clean) - return username_clean, password - - @staticmethod - def delete_user(username): - logger.debug("Deleting user %s from mumble." % username) - if MumbleUser.objects.filter(username=username).exists(): - MumbleUser.objects.filter(username=username).delete() - logger.info("Deleted user %s from mumble" % username) - return True - logger.error("Unable to delete user %s from mumble: MumbleUser model not found" % username) - return False - - @staticmethod - def update_user_password(username, password=None): - logger.debug("Updating mumble user %s password." % username) - if not password: - password = MumbleManager.__generate_random_pass() - pwhash = MumbleManager._gen_pwhash(password) - logger.debug("Proceeding with mumble user %s password update - pwhash starts with %s" % (username, pwhash[0:5])) - if MumbleUser.objects.filter(username=username).exists(): - model = MumbleUser.objects.get(username=username) - model.pwhash = pwhash - model.save() - return password - logger.error("User %s not found on mumble. Unable to update password." % username) - return "" - - @staticmethod - def update_groups(username, groups): - logger.debug("Updating mumble user %s groups %s" % (username, groups)) - safe_groups = list(set([g.replace(' ', '-') for g in groups])) - groups = '' - for g in safe_groups: - groups = groups + g + ',' - groups = groups.strip(',') - if MumbleUser.objects.filter(username=username).exists(): - logger.info("Updating mumble user %s groups to %s" % (username, safe_groups)) - model = MumbleUser.objects.get(username=username) - model.groups = groups - model.save() - else: - logger.error("User %s not found on mumble. Unable to update groups." % username) - - @staticmethod - def user_exists(username): - return MumbleUser.objects.filter(username=username).exists() diff --git a/services/migrations/0001_initial.py b/services/migrations/0001_initial.py index 5787cfb8..a482d393 100644 --- a/services/migrations/0001_initial.py +++ b/services/migrations/0001_initial.py @@ -17,16 +17,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name='AuthTS', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('auth_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.Group')), - ], - options={ - 'verbose_name': 'Auth / TS Group', - }, - ), migrations.CreateModel( name='DiscordAuthToken', fields=[ @@ -45,39 +35,4 @@ class Migration(migrations.Migration): ('service', models.CharField(choices=[(b'discourse', b'discourse'), (b'discord', b'discord')], max_length=254, unique=True)), ], ), - migrations.CreateModel( - name='MumbleUser', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('username', models.CharField(max_length=254, unique=True)), - ('pwhash', models.CharField(max_length=40)), - ('groups', models.TextField(blank=True, null=True)), - ], - ), - migrations.CreateModel( - name='TSgroup', - fields=[ - ('ts_group_id', models.IntegerField(primary_key=True, serialize=False)), - ('ts_group_name', models.CharField(max_length=30)), - ], - options={ - 'verbose_name': 'TS Group', - }, - ), - migrations.CreateModel( - name='UserTSgroup', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('ts_group', models.ManyToManyField(to='services.TSgroup')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'verbose_name': 'User TS Group', - }, - ), - migrations.AddField( - model_name='authts', - name='ts_group', - field=models.ManyToManyField(to='services.TSgroup'), - ), ] diff --git a/services/models.py b/services/models.py index 80b62c08..45bf9f0b 100644 --- a/services/models.py +++ b/services/models.py @@ -3,52 +3,6 @@ from django.utils.encoding import python_2_unicode_compatible from django.db import models -@python_2_unicode_compatible -class TSgroup(models.Model): - ts_group_id = models.IntegerField(primary_key=True) - ts_group_name = models.CharField(max_length=30) - - class Meta: - verbose_name = 'TS Group' - - def __str__(self): - return self.ts_group_name - - -@python_2_unicode_compatible -class AuthTS(models.Model): - auth_group = models.ForeignKey('auth.Group') - ts_group = models.ManyToManyField(TSgroup) - - class Meta: - verbose_name = 'Auth / TS Group' - - def __str__(self): - return self.auth_group.name - - -@python_2_unicode_compatible -class UserTSgroup(models.Model): - user = models.ForeignKey('auth.User') - ts_group = models.ManyToManyField(TSgroup) - - class Meta: - verbose_name = 'User TS Group' - - def __str__(self): - return self.user.name - - -@python_2_unicode_compatible -class MumbleUser(models.Model): - username = models.CharField(max_length=254, unique=True) - pwhash = models.CharField(max_length=40) - groups = models.TextField(blank=True, null=True) - - def __str__(self): - return self.username - - @python_2_unicode_compatible class GroupCache(models.Model): SERVICE_CHOICES = ( diff --git a/services/modules/__init__.py b/services/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/discord/__init__.py b/services/modules/discord/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/discord/admin.py b/services/modules/discord/admin.py new file mode 100644 index 00000000..51d0c0cf --- /dev/null +++ b/services/modules/discord/admin.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from django.contrib import admin +from .models import DiscordUser + + +class DiscordUserAdmin(admin.ModelAdmin): + list_display = ('user', 'uid') + search_fields = ('user__username', 'uid') + +admin.site.register(DiscordUser, DiscordUserAdmin) diff --git a/services/modules/discord/apps.py b/services/modules/discord/apps.py new file mode 100644 index 00000000..2d0e2667 --- /dev/null +++ b/services/modules/discord/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class DiscordServiceConfig(AppConfig): + name = 'discord' diff --git a/services/modules/discord/auth_hooks.py b/services/modules/discord/auth_hooks.py new file mode 100644 index 00000000..b1738073 --- /dev/null +++ b/services/modules/discord/auth_hooks.py @@ -0,0 +1,59 @@ +from __future__ import unicode_literals + +import logging + +from django.conf import settings +from django.template.loader import render_to_string + +from alliance_auth import hooks +from services.hooks import ServicesHook +from .tasks import DiscordTasks +from .urls import urlpatterns + +logger = logging.getLogger(__name__) + + +class DiscordService(ServicesHook): + def __init__(self): + ServicesHook.__init__(self) + self.urlpatterns = urlpatterns + self.name = 'discord' + self.service_ctrl_template = 'registered/discord_service_ctrl.html' + + def delete_user(self, user, notify_user=False): + logger.debug('Deleting user %s %s account' % (user, self.name)) + return DiscordTasks.delete_user(user, notify_user=notify_user) + + def update_groups(self, user): + logger.debug('Processing %s groups for %s' % (self.name, user)) + if DiscordTasks.has_account(user): + DiscordTasks.update_groups.delay(user.pk) + + def validate_user(self, user): + logger.debug('Validating user %s %s account' % (user, self.name)) + if DiscordTasks.has_account(user) and not self.service_active_for_user(user): + self.delete_user(user, notify_user=True) + + def sync_nickname(self, user): + logger.debug('Syncing %s nickname for user %s' % (self.name, user)) + DiscordTasks.update_nickname.delay(user.pk) + + def update_all_groups(self): + logger.debug('Update all %s groups called' % self.name) + DiscordTasks.update_all_groups.delay() + + def service_enabled_members(self): + return settings.ENABLE_AUTH_DISCORD or False + + def service_enabled_blues(self): + return settings.ENABLE_BLUE_DISCORD or False + + def render_services_ctrl(self, request): + return render_to_string(self.service_ctrl_template, { + 'discord_uid': request.user.discord.uid if DiscordTasks.has_account(request.user) else None, + }, request=request) + + +@hooks.register('services_hook') +def register_service(): + return DiscordService() diff --git a/services/managers/discord_manager.py b/services/modules/discord/manager.py similarity index 100% rename from services/managers/discord_manager.py rename to services/modules/discord/manager.py diff --git a/services/modules/discord/migrations/0001_initial.py b/services/modules/discord/migrations/0001_initial.py new file mode 100644 index 00000000..b9865117 --- /dev/null +++ b/services/modules/discord/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 03:14 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='DiscordUser', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='discord', serialize=False, to=settings.AUTH_USER_MODEL)), + ('uid', models.CharField(max_length=254)), + ], + ), + ] diff --git a/services/modules/discord/migrations/__init__.py b/services/modules/discord/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/discord/models.py b/services/modules/discord/models.py new file mode 100644 index 00000000..cfe38358 --- /dev/null +++ b/services/modules/discord/models.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible +from django.contrib.auth.models import User +from django.db import models + + +class DiscordUser(models.Model): + user = models.OneToOneField(User, + primary_key=True, + on_delete=models.CASCADE, + related_name='discord') + uid = models.CharField(max_length=254) + + def __str__(self): + return "{} - {}".format(self.user.username, self.uid) diff --git a/services/modules/discord/tasks.py b/services/modules/discord/tasks.py new file mode 100644 index 00000000..a616a546 --- /dev/null +++ b/services/modules/discord/tasks.py @@ -0,0 +1,131 @@ +from __future__ import unicode_literals + +import logging + +from alliance_auth.celeryapp import app +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist + +from eveonline.managers import EveManager +from notifications import notify +from services.modules.discord.manager import DiscordOAuthManager +from services.tasks import only_one +from .models import DiscordUser + +logger = logging.getLogger(__name__) + + +class DiscordTasks: + def __init__(self): + pass + + @classmethod + def add_user(cls, user, code): + user_id = DiscordOAuthManager.add_user(code) + if user_id: + discord_user = DiscordUser() + discord_user.user = user + discord_user.uid = user_id + discord_user.save() + if settings.DISCORD_SYNC_NAMES: + cls.update_nickname.delay(user.pk) + cls.update_groups.delay(user.pk) + return True + return False + + @classmethod + def delete_user(cls, user, notify_user=False): + if cls.has_account(user): + logger.debug("User %s has discord account %s. Deleting." % (user, user.discord.uid)) + if DiscordOAuthManager.delete_user(user.discord.uid): + user.discord.delete() + if notify_user: + notify(user, 'Discord Account Disabled', level='danger') + return True + return False + + @classmethod + def has_account(cls, user): + """ + Check if the user has an account (has a DiscordUser record) + :param user: django.contrib.auth.models.User + :return: bool + """ + try: + user.discord + except ObjectDoesNotExist: + return False + else: + return True + + @staticmethod + @app.task(bind=True) + def update_groups(task_self, pk): + user = User.objects.get(pk=pk) + logger.debug("Updating discord groups for user %s" % user) + if DiscordTasks.has_account(user): + groups = [] + for group in user.groups.all(): + groups.append(str(group.name)) + if len(groups) == 0: + logger.debug("No syncgroups found for user. Adding empty group.") + groups.append('empty') + logger.debug("Updating user %s discord groups to %s" % (user, groups)) + try: + DiscordOAuthManager.update_groups(user.discord.uid, groups) + except Exception as e: + if task_self: + logger.exception("Discord group sync failed for %s, retrying in 10 mins" % user) + raise task_self.retry(countdown=60 * 10) + else: + # Rethrow + raise e + logger.debug("Updated user %s discord groups." % user) + else: + logger.debug("User does not have a discord account, skipping") + + @staticmethod + @app.task + def update_all_groups(): + logger.debug("Updating ALL discord groups") + for discord_user in DiscordUser.objects.exclude(uid__exact=''): + DiscordTasks.update_groups.delay(discord_user.user.pk) + + @staticmethod + @app.task(bind=True) + def update_nickname(self, pk): + user = User.objects.get(pk=pk) + logger.debug("Updating discord nickname for user %s" % user) + if DiscordTasks.has_account(user): + character = EveManager.get_main_character(user) + logger.debug("Updating user %s discord nickname to %s" % (user, character.character_name)) + try: + DiscordOAuthManager.update_nickname(user.discord.uid, character.character_name) + except Exception as e: + if self: + logger.exception("Discord nickname sync failed for %s, retrying in 10 mins" % user) + raise self.retry(countdown=60 * 10) + else: + # Rethrow + raise e + logger.debug("Updated user %s discord nickname." % user) + else: + logger.debug("User %s does not have a discord account" % user) + + @staticmethod + @app.task + def update_all_nicknames(): + logger.debug("Updating ALL discord nicknames") + for discord_user in DiscordUser.objects.exclude(uid__exact=''): + DiscordTasks.update_nickname.delay(discord_user.user.user_id) + + @classmethod + def disable(cls): + if settings.ENABLE_AUTH_DISCORD: + logger.warn( + "ENABLE_AUTH_DISCORD still True, after disabling users will still be able to link Discord accounts") + if settings.ENABLE_BLUE_DISCORD: + logger.warn( + "ENABLE_BLUE_DISCORD still True, after disabling blues will still be able to link Discord accounts") + DiscordUser.objects.all().delete() diff --git a/services/modules/discord/templates/registered/discord_service_ctrl.html b/services/modules/discord/templates/registered/discord_service_ctrl.html new file mode 100644 index 00000000..f9315c9a --- /dev/null +++ b/services/modules/discord/templates/registered/discord_service_ctrl.html @@ -0,0 +1,27 @@ +{% load i18n %} + + + + Discord + + https://discordapp.com + + {% if not discord_uid %} + + + + {% else %} + + + + + + + {% endif %} + {% if request.user.is_superuser %} + + {% endif %} + + diff --git a/services/modules/discord/tests.py b/services/modules/discord/tests.py new file mode 100644 index 00000000..3ac9b27e --- /dev/null +++ b/services/modules/discord/tests.py @@ -0,0 +1,193 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase, RequestFactory +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist + +from alliance_auth.tests.auth_utils import AuthUtils + +from .auth_hooks import DiscordService +from .models import DiscordUser +from .tasks import DiscordTasks + +MODULE_PATH = 'services.modules.discord' + + +class DiscordHooksTestCase(TestCase): + def setUp(self): + self.member = 'member_user' + member = AuthUtils.create_member(self.member) + DiscordUser.objects.create(user=member, uid='12345') + self.blue = 'blue_user' + blue = AuthUtils.create_blue(self.blue) + DiscordUser.objects.create(user=blue, uid='67891') + self.none_user = 'none_user' + none_user = AuthUtils.create_user(self.none_user) + self.service = DiscordService + + def test_has_account(self): + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(DiscordTasks.has_account(member)) + self.assertTrue(DiscordTasks.has_account(blue)) + self.assertFalse(DiscordTasks.has_account(none_user)) + + def test_service_enabled(self): + service = self.service() + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(service.service_enabled_members()) + self.assertTrue(service.service_enabled_blues()) + + self.assertTrue(service.service_active_for_user(member)) + self.assertTrue(service.service_active_for_user(blue)) + self.assertFalse(service.service_active_for_user(none_user)) + + @mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager') + def test_update_all_groups(self, manager): + service = self.service() + service.update_all_groups() + # Check member and blue user have groups updated + self.assertTrue(manager.update_groups.called) + self.assertEqual(manager.update_groups.call_count, 2) + + def test_update_groups(self): + # Check member has Member group updated + with mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager') as manager: + service = self.service() + member = User.objects.get(username=self.member) + service.update_groups(member) + self.assertTrue(manager.update_groups.called) + args, kwargs = manager.update_groups.call_args + user_id, groups = args + self.assertIn(settings.DEFAULT_AUTH_GROUP, groups) + self.assertEqual(user_id, member.discord.uid) + + # Check none user does not have groups updated + with mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager') as manager: + service = self.service() + none_user = User.objects.get(username=self.none_user) + service.update_groups(none_user) + self.assertFalse(manager.update_groups.called) + + @mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager') + def test_validate_user(self, manager): + service = self.service() + # Test member is not deleted + member = User.objects.get(username=self.member) + service.validate_user(member) + self.assertTrue(member.discord) + + # Test none user is deleted + none_user = User.objects.get(username=self.none_user) + DiscordUser.objects.create(user=none_user, uid='abc123') + service.validate_user(none_user) + self.assertTrue(manager.delete_user.called) + with self.assertRaises(ObjectDoesNotExist): + none_discord = User.objects.get(username=self.none_user).discord + + @mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager') + def test_sync_nickname(self, manager): + service = self.service() + member = User.objects.get(username=self.member) + AuthUtils.add_main_character(member, 'test user', '12345', corp_ticker='AAUTH') + + service.sync_nickname(member) + + self.assertTrue(manager.update_nickname.called) + args, kwargs = manager.update_nickname.call_args + self.assertEqual(args[0], member.discord.uid) + self.assertEqual(args[1], 'test user') + + @mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager') + def test_delete_user(self, manager): + member = User.objects.get(username=self.member) + + service = self.service() + result = service.delete_user(member) + + self.assertTrue(result) + self.assertTrue(manager.delete_user.called) + with self.assertRaises(ObjectDoesNotExist): + discord_user = User.objects.get(username=self.member).discord + + def test_render_services_ctrl(self): + service = self.service() + member = User.objects.get(username=self.member) + request = RequestFactory().get('/en/services/') + request.user = member + + response = service.render_services_ctrl(request) + self.assertTemplateUsed(service.service_ctrl_template) + self.assertIn('/discord/reset/', response) + self.assertIn('/discord/deactivate/', response) + + # Test register becomes available + member.discord.delete() + member = User.objects.get(username=self.member) + request.user = member + response = service.render_services_ctrl(request) + self.assertIn('/discord/activate/', response) + + # TODO: Test update nicknames + + +class DiscordViewsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.member.set_password('password') + self.member.save() + + def login(self): + self.client.login(username=self.member.username, password='password') + + @mock.patch(MODULE_PATH + '.views.DiscordOAuthManager') + def test_activate(self, manager): + self.login() + manager.generate_oauth_redirect_url.return_value = '/example.com/oauth/' + response = self.client.get('/discord/activate/', follow=False) + self.assertRedirects(response, expected_url='/example.com/oauth/', target_status_code=404) + + @mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager') + def test_callback(self, manager): + self.login() + manager.add_user.return_value = '1234' + response = self.client.get('/discord/callback/', data={'code': '1234'}) + + self.assertTrue(manager.add_user.called) + self.assertEqual(manager.update_nickname.called, settings.DISCORD_SYNC_NAMES) + self.assertEqual(self.member.discord.uid, '1234') + self.assertRedirects(response, expected_url='/en/services/', target_status_code=200) + + @mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager') + def test_reset(self, manager): + self.login() + DiscordUser.objects.create(user=self.member, uid='12345') + manager.delete_user.return_value = True + + response = self.client.get('/discord/reset/') + + self.assertRedirects(response, expected_url='/discord/activate/', target_status_code=302) + + @mock.patch(MODULE_PATH + '.tasks.DiscordOAuthManager') + def test_deactivate(self, manager): + self.login() + DiscordUser.objects.create(user=self.member, uid='12345') + manager.delete_user.return_value = True + + response = self.client.get('/discord/deactivate/') + + self.assertTrue(manager.delete_user.called) + self.assertRedirects(response, expected_url='/en/services/', target_status_code=200) + with self.assertRaises(ObjectDoesNotExist): + discord_user = User.objects.get(pk=self.member.pk).discord diff --git a/services/modules/discord/urls.py b/services/modules/discord/urls.py new file mode 100644 index 00000000..17862c89 --- /dev/null +++ b/services/modules/discord/urls.py @@ -0,0 +1,17 @@ +from __future__ import unicode_literals +from django.conf.urls import url, include + +from . import views + +module_urls = [ + # Discord Service Control + url(r'^activate/$', views.activate_discord, name='auth_activate_discord'), + url(r'^deactivate/$', views.deactivate_discord, name='auth_deactivate_discord'), + url(r'^reset/$', views.reset_discord, name='auth_reset_discord'), + url(r'^callback/$', views.discord_callback, name='auth_discord_callback'), + url(r'^add_bot/$', views.discord_add_bot, name='auth_discord_add_bot'), +] + +urlpatterns = [ + url(r'^discord/', include(module_urls)) +] diff --git a/services/modules/discord/views.py b/services/modules/discord/views.py new file mode 100644 index 00000000..3223c5b9 --- /dev/null +++ b/services/modules/discord/views.py @@ -0,0 +1,71 @@ +from __future__ import unicode_literals + +import logging + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.contrib.auth.decorators import user_passes_test +from django.shortcuts import redirect + +from authentication.decorators import members_and_blues +from .manager import DiscordOAuthManager +from .tasks import DiscordTasks +from services.views import superuser_test + + +logger = logging.getLogger(__name__) + + +@login_required +@members_and_blues() +def deactivate_discord(request): + logger.debug("deactivate_discord called by user %s" % request.user) + if DiscordTasks.delete_user(request.user): + logger.info("Successfully deactivated discord for user %s" % request.user) + messages.success(request, 'Deactivated Discord account.') + else: + logger.error("Unsuccessful attempt to deactivate discord for user %s" % request.user) + messages.error(request, 'An error occurred while processing your Discord account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def reset_discord(request): + logger.debug("reset_discord called by user %s" % request.user) + if DiscordTasks.delete_user(request.user): + logger.info("Successfully deleted discord user for user %s - forwarding to discord activation." % request.user) + return redirect("auth_activate_discord") + logger.error("Unsuccessful attempt to reset discord for user %s" % request.user) + messages.error(request, 'An error occurred while processing your Discord account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def activate_discord(request): + logger.debug("activate_discord called by user %s" % request.user) + return redirect(DiscordOAuthManager.generate_oauth_redirect_url()) + + +@login_required +@members_and_blues() +def discord_callback(request): + logger.debug("Received Discord callback for activation of user %s" % request.user) + code = request.GET.get('code', None) + if not code: + logger.warn("Did not receive OAuth code from callback of user %s" % request.user) + return redirect("auth_services") + if DiscordTasks.add_user(request.user, code): + logger.info("Successfully activated Discord for user %s" % request.user) + messages.success(request, 'Activated Discord account.') + else: + logger.error("Failed to activate Discord for user %s" % request.user) + messages.error(request, 'An error occurred while processing your Discord account.') + return redirect("auth_services") + + +@login_required +@user_passes_test(superuser_test) +def discord_add_bot(request): + return redirect(DiscordOAuthManager.generate_bot_add_url()) diff --git a/services/modules/discourse/__init__.py b/services/modules/discourse/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/discourse/admin.py b/services/modules/discourse/admin.py new file mode 100644 index 00000000..81240d4a --- /dev/null +++ b/services/modules/discourse/admin.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from django.contrib import admin +from .models import DiscourseUser + + +class DiscourseUserAdmin(admin.ModelAdmin): + list_display = ('user',) + search_fields = ('user__username',) + +admin.site.register(DiscourseUser, DiscourseUserAdmin) diff --git a/services/modules/discourse/apps.py b/services/modules/discourse/apps.py new file mode 100644 index 00000000..b5fc3ab7 --- /dev/null +++ b/services/modules/discourse/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class DiscourseServiceConfig(AppConfig): + name = 'discourse' diff --git a/services/modules/discourse/auth_hooks.py b/services/modules/discourse/auth_hooks.py new file mode 100644 index 00000000..3af0276f --- /dev/null +++ b/services/modules/discourse/auth_hooks.py @@ -0,0 +1,57 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.template.loader import render_to_string + +from services.hooks import ServicesHook +from alliance_auth import hooks +from eveonline.managers import EveManager + +from .urls import urlpatterns +from .tasks import DiscourseTasks + +import logging + +logger = logging.getLogger(__name__) + + +class DiscourseService(ServicesHook): + def __init__(self): + ServicesHook.__init__(self) + self.urlpatterns = urlpatterns + self.name = 'discourse' + self.service_ctrl_template = 'registered/discourse_service_ctrl.html' + + def delete_user(self, user, notify_user=False): + logger.debug('Deleting user %s %s account' % (user, self.name)) + return DiscourseTasks.delete_user(user, notify_user=notify_user) + + def update_groups(self, user): + logger.debug('Processing %s groups for %s' % (self.name, user)) + if DiscourseTasks.has_account(user): + DiscourseTasks.update_groups.delay(user.pk) + + def validate_user(self, user): + logger.debug('Validating user %s %s account' % (user, self.name)) + if DiscourseTasks.has_account(user) and not self.service_active_for_user(user): + self.delete_user(user, notify_user=True) + + def update_all_groups(self): + logger.debug('Update all %s groups called' % self.name) + DiscourseTasks.update_all_groups.delay() + + def service_enabled_members(self): + return settings.ENABLE_AUTH_DISCOURSE or False + + def service_enabled_blues(self): + return settings.ENABLE_BLUE_DISCOURSE or False + + def render_services_ctrl(self, request): + return render_to_string(self.service_ctrl_template, { + 'char': EveManager.get_main_character(request.user) + }, request=request) + + +@hooks.register('services_hook') +def register_service(): + return DiscourseService() diff --git a/services/managers/discourse_manager.py b/services/modules/discourse/manager.py similarity index 99% rename from services/managers/discourse_manager.py rename to services/modules/discourse/manager.py index 1953bf57..9d1413b6 100644 --- a/services/managers/discourse_manager.py +++ b/services/modules/discourse/manager.py @@ -1,7 +1,8 @@ from __future__ import unicode_literals import logging import requests -import os +import random +import string import datetime import json import re @@ -11,10 +12,12 @@ from services.models import GroupCache logger = logging.getLogger(__name__) + class DiscourseError(Exception): def __init__(self, endpoint, errors): self.endpoint = endpoint self.errors = errors + def __str__(self): return "API execution failed.\nErrors: %s\nEndpoint: %s" % (self.errors, self.endpoint) @@ -188,7 +191,7 @@ class DiscourseManager: @staticmethod def __generate_random_pass(): - return os.urandom(8).encode('hex') + return ''.join([random.choice(string.ascii_letters + string.digits) for n in range(16)]) @staticmethod def __get_groups(): diff --git a/services/modules/discourse/migrations/0001_initial.py b/services/modules/discourse/migrations/0001_initial.py new file mode 100644 index 00000000..ef80496c --- /dev/null +++ b/services/modules/discourse/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 03:15 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='DiscourseUser', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='discourse', serialize=False, to=settings.AUTH_USER_MODEL)), + ('enabled', models.BooleanField()), + ], + ), + ] diff --git a/services/modules/discourse/migrations/__init__.py b/services/modules/discourse/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/discourse/models.py b/services/modules/discourse/models.py new file mode 100644 index 00000000..3b2c6a8c --- /dev/null +++ b/services/modules/discourse/models.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible +from django.contrib.auth.models import User +from django.db import models + + +class DiscourseUser(models.Model): + user = models.OneToOneField(User, + primary_key=True, + on_delete=models.CASCADE, + related_name='discourse') + enabled = models.BooleanField() + + def __str__(self): + return self.user.username diff --git a/services/modules/discourse/tasks.py b/services/modules/discourse/tasks.py new file mode 100644 index 00000000..0c59ee8c --- /dev/null +++ b/services/modules/discourse/tasks.py @@ -0,0 +1,62 @@ +from __future__ import unicode_literals + +from alliance_auth.celeryapp import app +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from notifications import notify + +from services.tasks import only_one + +from .manager import DiscourseManager +from .models import DiscourseUser + +import logging + +logger = logging.getLogger(__name__) + + +class DiscourseTasks: + def __init__(self): + pass + + @classmethod + def delete_user(cls, user, notify_user=False): + if cls.has_account(user) and user.discourse.enabled: + logger.debug("User %s has a Discourse account. Disabling login." % user) + if DiscourseManager.disable_user(user): + user.discourse.delete() + if notify_user: + notify(user, 'Discourse Account Disabled', level='danger') + return True + return False + + @staticmethod + def has_account(user): + """ + Check if the user has a discourse account + :param user: django.contrib.auth.models.User + :return: bool + """ + try: + return user.discourse.enabled + except ObjectDoesNotExist: + return False + + @staticmethod + @app.task(bind=True) + def update_groups(self, pk): + user = User.objects.get(pk=pk) + logger.debug("Updating discourse groups for user %s" % user) + try: + DiscourseManager.update_groups(user) + except: + logger.warn("Discourse group sync failed for %s, retrying in 10 mins" % user) + raise self.retry(countdown=60 * 10) + logger.debug("Updated user %s discourse groups." % user) + + @staticmethod + @app.task + def update_all_groups(): + logger.debug("Updating ALL discourse groups") + for discourse_user in DiscourseUser.objects.filter(enabled=True): + DiscourseTasks.update_groups.delay(discourse_user.user.pk) diff --git a/services/modules/discourse/templates/registered/discourse_service_ctrl.html b/services/modules/discourse/templates/registered/discourse_service_ctrl.html new file mode 100644 index 00000000..7cae0cd2 --- /dev/null +++ b/services/modules/discourse/templates/registered/discourse_service_ctrl.html @@ -0,0 +1,8 @@ +{% load i18n %} + +Discourse +{{ char.character_name }} +{{ DISCOURSE_URL }} + + + diff --git a/services/modules/discourse/tests.py b/services/modules/discourse/tests.py new file mode 100644 index 00000000..ade9f3e6 --- /dev/null +++ b/services/modules/discourse/tests.py @@ -0,0 +1,135 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase, RequestFactory +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist + +from alliance_auth.tests.auth_utils import AuthUtils + +from .auth_hooks import DiscourseService +from .models import DiscourseUser +from .tasks import DiscourseTasks + +MODULE_PATH = 'services.modules.discourse' + + +class DiscourseHooksTestCase(TestCase): + def setUp(self): + self.member = 'member_user' + member = AuthUtils.create_member(self.member) + DiscourseUser.objects.create(user=member, enabled=True) + self.blue = 'blue_user' + blue = AuthUtils.create_blue(self.blue) + DiscourseUser.objects.create(user=blue, enabled=True) + self.none_user = 'none_user' + none_user = AuthUtils.create_user(self.none_user) + self.service = DiscourseService + + def test_has_account(self): + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(DiscourseTasks.has_account(member)) + self.assertTrue(DiscourseTasks.has_account(blue)) + self.assertFalse(DiscourseTasks.has_account(none_user)) + + def test_service_enabled(self): + service = self.service() + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(service.service_enabled_members()) + self.assertTrue(service.service_enabled_blues()) + + self.assertEqual(service.service_active_for_user(member), settings.ENABLE_AUTH_DISCOURSE) + self.assertEqual(service.service_active_for_user(blue), settings.ENABLE_BLUE_DISCOURSE) + self.assertFalse(service.service_active_for_user(none_user)) + + @mock.patch(MODULE_PATH + '.tasks.DiscourseManager') + def test_update_all_groups(self, manager): + service = self.service() + service.update_all_groups() + # Check member and blue user have groups updated + self.assertTrue(manager.update_groups.called) + self.assertEqual(manager.update_groups.call_count, 2) + + def test_update_groups(self): + # Check member has Member group updated + with mock.patch(MODULE_PATH + '.tasks.DiscourseManager') as manager: + service = self.service() + member = User.objects.get(username=self.member) + service.update_groups(member) + self.assertTrue(manager.update_groups.called) + args, kwargs = manager.update_groups.call_args + user, = args + self.assertEqual(user, member) + + # Check none user does not have groups updated + with mock.patch(MODULE_PATH + '.tasks.DiscourseManager') as manager: + service = self.service() + none_user = User.objects.get(username=self.none_user) + service.update_groups(none_user) + self.assertFalse(manager.update_groups.called) + + @mock.patch(MODULE_PATH + '.tasks.DiscourseManager') + def test_validate_user(self, manager): + service = self.service() + # Test member is not deleted + member = User.objects.get(username=self.member) + service.validate_user(member) + self.assertTrue(member.discourse) + + # Test none user is deleted + none_user = User.objects.get(username=self.none_user) + DiscourseUser.objects.create(user=none_user, enabled=True) + service.validate_user(none_user) + self.assertTrue(manager.disable_user.called) + with self.assertRaises(ObjectDoesNotExist): + none_discourse = User.objects.get(username=self.none_user).discourse + + @mock.patch(MODULE_PATH + '.tasks.DiscourseManager') + def test_delete_user(self, manager): + member = User.objects.get(username=self.member) + + service = self.service() + result = service.delete_user(member) + + self.assertTrue(result) + self.assertTrue(manager.disable_user.called) + with self.assertRaises(ObjectDoesNotExist): + discourse_user = User.objects.get(username=self.member).discourse + + def test_render_services_ctrl(self): + service = self.service() + member = User.objects.get(username=self.member) + request = RequestFactory().get('/en/services/') + request.user = member + + response = service.render_services_ctrl(request) + self.assertTemplateUsed(service.service_ctrl_template) + self.assertIn('href="%s"' % settings.DISCOURSE_URL, response) + + +class DiscourseViewsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.member.set_password('password') + self.member.save() + AuthUtils.add_main_character(self.member, 'auth_member', '12345', corp_id='111', corp_name='Test Corporation') + + @mock.patch(MODULE_PATH + '.tasks.DiscourseManager') + def test_sso_member(self, manager): + self.client.login(username=self.member.username, password='password') + data = {'sso': 'bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGI%3D%0A', + 'sig': '2828aa29899722b35a2f191d34ef9b3ce695e0e6eeec47deb46d588d70c7cb56'} + response = self.client.get('/discourse/sso', data=data, follow=False) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url[:37], 'https://example.com/session/sso_login') diff --git a/services/modules/discourse/urls.py b/services/modules/discourse/urls.py new file mode 100644 index 00000000..cf4154cb --- /dev/null +++ b/services/modules/discourse/urls.py @@ -0,0 +1,9 @@ +from __future__ import unicode_literals +from django.conf.urls import url + +from . import views + +urlpatterns = [ + # Discourse Service Control + url(r'^discourse/sso$', views.discourse_sso, name='auth_discourse_sso'), +] diff --git a/services/modules/discourse/views.py b/services/modules/discourse/views.py new file mode 100644 index 00000000..670c4154 --- /dev/null +++ b/services/modules/discourse/views.py @@ -0,0 +1,119 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect +from authentication.states import MEMBER_STATE, BLUE_STATE, NONE_STATE +from eveonline.models import EveCharacter +from eveonline.managers import EveManager +from authentication.models import AuthServicesInfo + +from .manager import DiscourseManager +from .tasks import DiscourseTasks +from .models import DiscourseUser + +import base64 +import hmac +import hashlib + +try: + from urllib import unquote, urlencode +except ImportError: #py3 + from urllib.parse import unquote, urlencode +try: + from urlparse import parse_qs +except ImportError: #py3 + from urllib.parse import parse_qs + +import logging + +logger = logging.getLogger(__name__) + + +@login_required +def discourse_sso(request): + + ## Check if user has access + + auth = AuthServicesInfo.objects.get(user=request.user) + if not request.user.is_superuser: + if not settings.ENABLE_AUTH_DISCOURSE and auth.state == MEMBER_STATE: + messages.error(request, 'Members are not authorized to access Discourse.') + return redirect('auth_dashboard') + elif not settings.ENABLE_BLUE_DISCOURSE and auth.state == BLUE_STATE: + messages.error(request, 'Blues are not authorized to access Discourse.') + return redirect('auth_dashboard') + elif auth.state == NONE_STATE: + messages.error(request, 'You are not authorized to access Discourse.') + return redirect('auth_dashboard') + + if not auth.main_char_id: + messages.error(request, "You must have a main character set to access Discourse.") + return redirect('auth_characters') + + main_char = EveManager.get_main_character(request.user) + if main_char is None: + messages.error(request, "Your main character is missing a database model. Please select a new one.") + return redirect('auth_characters') + + payload = request.GET.get('sso') + signature = request.GET.get('sig') + + if None in [payload, signature]: + messages.error(request, 'No SSO payload or signature. Please contact support if this problem persists.') + return redirect('auth_dashboard') + + ## Validate the payload + + try: + payload = unquote(payload).encode('utf-8') + decoded = base64.decodestring(payload).decode('utf-8') + assert 'nonce' in decoded + assert len(payload) > 0 + except AssertionError: + messages.error(request, 'Invalid payload. Please contact support if this problem persists.') + return redirect('auth_dashboard') + + key = str(settings.DISCOURSE_SSO_SECRET).encode('utf-8') + h = hmac.new(key, payload, digestmod=hashlib.sha256) + this_signature = h.hexdigest() + + if this_signature != signature: + messages.error(request, 'Invalid payload. Please contact support if this problem persists.') + return redirect('auth_dashboard') + + ## Build the return payload + + username = DiscourseManager._sanitize_username(main_char.character_name) + + qs = parse_qs(decoded) + params = { + 'nonce': qs['nonce'][0], + 'email': request.user.email, + 'external_id': request.user.pk, + 'username': username, + 'name': username, + } + + if auth.main_char_id: + params['avatar_url'] = 'https://image.eveonline.com/Character/%s_256.jpg' % auth.main_char_id + + return_payload = base64.encodestring(urlencode(params).encode('utf-8')) + h = hmac.new(key, return_payload, digestmod=hashlib.sha256) + query_string = urlencode({'sso': return_payload, 'sig': h.hexdigest()}) + + ## Record activation and queue group sync + + if not DiscourseTasks.has_account(request.user): + discourse_user = DiscourseUser() + discourse_user.user = request.user + discourse_user.enabled = True + discourse_user.save() + DiscourseTasks.update_groups.apply_async(args=[request.user.pk], countdown=30) # wait 30s for new user creation on Discourse + + ## Redirect back to Discourse + + url = '%s/session/sso_login' % settings.DISCOURSE_URL + return redirect('%s?%s' % (url, query_string)) + diff --git a/services/modules/example/__init__.py b/services/modules/example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/example/apps.py b/services/modules/example/apps.py new file mode 100644 index 00000000..8d49787c --- /dev/null +++ b/services/modules/example/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class ExampleServiceConfig(AppConfig): + name = 'example_service' diff --git a/services/modules/example/auth_hooks.py b/services/modules/example/auth_hooks.py new file mode 100644 index 00000000..fecdecf7 --- /dev/null +++ b/services/modules/example/auth_hooks.py @@ -0,0 +1,44 @@ +from __future__ import unicode_literals + +from django.template.loader import render_to_string + +from services.hooks import ServicesHook +from alliance_auth import hooks + +from .urls import urlpatterns + + +class ExampleService(ServicesHook): + def __init__(self): + ServicesHook.__init__(self) + self.urlpatterns = urlpatterns + self.service_url = 'http://exampleservice.example.com' + + """ + Overload base methods here to implement functionality + """ + + def render_services_ctrl(self, request): + """ + Example for rendering the service control panel row + You can override the default template and create a + custom one if you wish. + :param request: + :return: + """ + urls = self.Urls() + urls.auth_activate = 'auth_example_activate' + urls.auth_deactivate = 'auth_example_deactivate' + urls.auth_reset_password = 'auth_example_reset_password' + urls.auth_set_password = 'auth_example_set_password' + return render_to_string(self.service_ctrl_template, { + 'service_name': self.title, + 'urls': urls, + 'service_url': self.service_url, + 'username': 'example username' + }, request=request) + + +@hooks.register('services_hook') +def register_service(): + return ExampleService() diff --git a/services/modules/example/models.py b/services/modules/example/models.py new file mode 100644 index 00000000..692f3152 --- /dev/null +++ b/services/modules/example/models.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible +from django.contrib.auth.models import User +from django.db import models + + +@python_2_unicode_compatible +class ExampleUser(models.Model): + user = models.OneToOneField(User, + primary_key=True, + on_delete=models.CASCADE, + related_name='example') + username = models.CharField(max_length=254) + + def __str__(self): + return self.username diff --git a/services/modules/example/urls.py b/services/modules/example/urls.py new file mode 100644 index 00000000..5c895021 --- /dev/null +++ b/services/modules/example/urls.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from django.conf.urls import url, include + +module_urls = [ + # Add your module URLs here +] + +urlpatterns = [ + url(r'^example/', include(module_urls)), +] diff --git a/services/modules/example/views.py b/services/modules/example/views.py new file mode 100644 index 00000000..36e3c085 --- /dev/null +++ b/services/modules/example/views.py @@ -0,0 +1 @@ +# Add your Views here diff --git a/services/modules/ipboard/__init__.py b/services/modules/ipboard/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/ipboard/admin.py b/services/modules/ipboard/admin.py new file mode 100644 index 00000000..9351245c --- /dev/null +++ b/services/modules/ipboard/admin.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from django.contrib import admin +from .models import IpboardUser + + +class IpboardUserAdmin(admin.ModelAdmin): + list_display = ('user', 'username') + search_fields = ('user__username', 'username') + +admin.site.register(IpboardUser, IpboardUserAdmin) diff --git a/services/modules/ipboard/apps.py b/services/modules/ipboard/apps.py new file mode 100644 index 00000000..6bad20bb --- /dev/null +++ b/services/modules/ipboard/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class IpboardServiceConfig(AppConfig): + name = 'ipboard' diff --git a/services/modules/ipboard/auth_hooks.py b/services/modules/ipboard/auth_hooks.py new file mode 100644 index 00000000..7bbf095c --- /dev/null +++ b/services/modules/ipboard/auth_hooks.py @@ -0,0 +1,68 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.template.loader import render_to_string + +from services.hooks import ServicesHook +from alliance_auth import hooks + +from .urls import urlpatterns +from .tasks import IpboardTasks + +import logging + +logger = logging.getLogger(__name__) + + +class IpboardService(ServicesHook): + def __init__(self): + ServicesHook.__init__(self) + self.name = 'ipboard' + self.service_url = settings.IPBOARD_ENDPOINT + self.urlpatterns = urlpatterns + + @property + def title(self): + return 'IPBoard Forums' + + def delete_user(self, user, notify_user=False): + logger.debug('Deleting user %s %s account' % (user, self.name)) + return IpboardTasks.delete_user(user, notify_user=notify_user) + + def update_groups(self, user): + logger.debug("Updating %s groups for %s" % (self.name, user)) + if IpboardTasks.has_account(user): + IpboardTasks.update_groups.delay(user.pk) + + def validate_user(self, user): + logger.debug('Validating user %s %s account' % (user, self.name)) + if IpboardTasks.has_account(user) and not self.service_active_for_user(user): + self.delete_user(user, notify_user=True) + + def update_all_groups(self): + logger.debug('Update all %s groups called' % self.name) + IpboardTasks.update_all_groups.delay() + + def service_enabled_members(self): + return settings.ENABLE_AUTH_IPBOARD or False + + def service_enabled_blues(self): + return settings.ENABLE_BLUE_IPBOARD or False + + def render_services_ctrl(self, request): + urls = self.Urls() + urls.auth_activate = 'auth_activate_ipboard' + urls.auth_deactivate = 'auth_deactivate_ipboard' + urls.auth_reset_password = 'auth_reset_ipboard_password' + urls.auth_set_password = 'auth_set_ipboard_password' + return render_to_string(self.service_ctrl_template, { + 'service_name': self.title, + 'urls': urls, + 'service_url': self.service_url, + 'username': request.user.ipboard.username if IpboardTasks.has_account(request.user) else '', + }, request=request) + + +@hooks.register('services_hook') +def register_service(): + return IpboardService() diff --git a/services/managers/ipboard_manager.py b/services/modules/ipboard/manager.py similarity index 92% rename from services/managers/ipboard_manager.py rename to services/modules/ipboard/manager.py index 4f6e5124..ecc59fdd 100755 --- a/services/managers/ipboard_manager.py +++ b/services/modules/ipboard/manager.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals -import os +import random +import string import re from hashlib import md5 try: @@ -27,7 +28,11 @@ class IPBoardManager: @staticmethod def __generate_random_pass(): - return os.urandom(8).encode('hex') + return ''.join([random.choice(string.ascii_letters + string.digits) for n in range(16)]) + + @staticmethod + def _gen_pwhash(password): + return md5(password.encode('utf-8')).hexdigest() @staticmethod def _sanitize_groupname(name): @@ -49,13 +54,13 @@ class IPBoardManager: except: return {} - @staticmethod - def add_user(username, email): + @classmethod + def add_user(cls, username, email): """ Add user to service """ sanatized = str(IPBoardManager.__santatize_username(username)) logger.debug("Adding user to IPBoard with username %s" % sanatized) plain_password = IPBoardManager.__generate_random_pass() - password = md5(plain_password).hexdigest() + password = cls._gen_pwhash(plain_password) IPBoardManager.exec_xmlrpc('createUser', username=sanatized, email=str(email), display_name=sanatized, md5_passwordHash=password) logger.info("Added IPBoard user with username %s" % sanatized) @@ -75,10 +80,10 @@ class IPBoardManager: logger.info("Disabled IPBoard user with username %s" % username) return username - @staticmethod - def update_user(username, email, password): + @classmethod + def update_user(cls, username, email, password): """ Add user to service """ - password = md5(password).hexdigest() + password = cls._gen_pwhash(password) logger.debug("Updating IPBoard username %s with email %s and password hash starting with %s" % ( username, email, password[0:5])) IPBoardManager.exec_xmlrpc('updateUser', username=username, email=email, md5_passwordHash=password) diff --git a/services/modules/ipboard/migrations/0001_initial.py b/services/modules/ipboard/migrations/0001_initial.py new file mode 100644 index 00000000..448c1064 --- /dev/null +++ b/services/modules/ipboard/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 03:27 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='IpboardUser', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='ipboard', serialize=False, to=settings.AUTH_USER_MODEL)), + ('username', models.CharField(max_length=254)), + ], + ), + ] diff --git a/services/modules/ipboard/migrations/__init__.py b/services/modules/ipboard/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/ipboard/models.py b/services/modules/ipboard/models.py new file mode 100644 index 00000000..8cb0cb4c --- /dev/null +++ b/services/modules/ipboard/models.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible +from django.contrib.auth.models import User +from django.db import models + + +@python_2_unicode_compatible +class IpboardUser(models.Model): + user = models.OneToOneField(User, + primary_key=True, + on_delete=models.CASCADE, + related_name='ipboard') + username = models.CharField(max_length=254) + + def __str__(self): + return self.username diff --git a/services/modules/ipboard/tasks.py b/services/modules/ipboard/tasks.py new file mode 100644 index 00000000..0e1a4ace --- /dev/null +++ b/services/modules/ipboard/tasks.py @@ -0,0 +1,73 @@ +from __future__ import unicode_literals + +from alliance_auth.celeryapp import app +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from notifications import notify + +from .manager import IPBoardManager +from .models import IpboardUser + +import logging + +logger = logging.getLogger(__name__) + + +class IpboardTasks: + def __init__(self): + pass + + @classmethod + def delete_user(cls, user, notify_user=False): + if cls.has_account(user): + if IPBoardManager.disable_user(user.ipboard.username): + user.ipboard.delete() + if notify_user: + notify(user, 'IPBoard Account Disabled', level='danger') + return True + return False + + @staticmethod + def has_account(user): + try: + return user.ipboard.username != '' + except ObjectDoesNotExist: + return False + + @staticmethod + @app.task(bind=True) + def update_groups(self, pk): + user = User.objects.get(pk=pk) + logger.debug("Updating user %s ipboard groups." % user) + groups = [] + for group in user.groups.all(): + groups.append(str(group.name)) + if len(groups) == 0: + groups.append('empty') + logger.debug("Updating user %s ipboard groups to %s" % (user, groups)) + try: + IPBoardManager.update_groups(user.ipboard.username, groups) + except: + logger.exception("IPBoard group sync failed for %s, retrying in 10 mins" % user) + raise self.retry(countdown=60 * 10) + logger.debug("Updated user %s ipboard groups." % user) + + @staticmethod + @app.task + def update_all_groups(): + logger.debug("Updating ALL ipboard groups") + for ipboard_user in IpboardUser.objects.exclude(username__exact=''): + IpboardTasks.update_groups.delay(ipboard_user.user.pk) + + @staticmethod + @app.task + def disable(): + if settings.ENABLE_AUTH_IPBOARD: + logger.warn( + "ENABLE_AUTH_IPBOARD still True, after disabling users will still be able to create IPBoard accounts") + if settings.ENABLE_BLUE_IPBOARD: + logger.warn( + "ENABLE_BLUE_IPBOARD still True, after disabling blues will still be able to create IPBoard accounts") + logger.debug("Deleting all Ipboard Users") + IpboardUser.objects.all().delete() diff --git a/services/modules/ipboard/tests.py b/services/modules/ipboard/tests.py new file mode 100644 index 00000000..cb3b127d --- /dev/null +++ b/services/modules/ipboard/tests.py @@ -0,0 +1,207 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase, RequestFactory +from django.conf import settings +from django.contrib.auth.models import User +from django import urls +from django.core.exceptions import ObjectDoesNotExist + +from alliance_auth.tests.auth_utils import AuthUtils + +from .auth_hooks import IpboardService +from .models import IpboardUser +from .tasks import IpboardTasks +from .manager import IPBoardManager + +MODULE_PATH = 'services.modules.ipboard' + + +class IpboardHooksTestCase(TestCase): + def setUp(self): + self.member = 'member_user' + member = AuthUtils.create_member(self.member) + IpboardUser.objects.create(user=member, username=self.member) + self.blue = 'blue_user' + blue = AuthUtils.create_blue(self.blue) + IpboardUser.objects.create(user=blue, username=self.blue) + self.none_user = 'none_user' + none_user = AuthUtils.create_user(self.none_user) + self.service = IpboardService + + def test_has_account(self): + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(IpboardTasks.has_account(member)) + self.assertTrue(IpboardTasks.has_account(blue)) + self.assertFalse(IpboardTasks.has_account(none_user)) + + def test_service_enabled(self): + service = self.service() + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(service.service_enabled_members()) + self.assertTrue(service.service_enabled_blues()) + + self.assertEqual(service.service_active_for_user(member), settings.ENABLE_AUTH_IPBOARD) + self.assertEqual(service.service_active_for_user(blue), settings.ENABLE_BLUE_IPBOARD) + self.assertFalse(service.service_active_for_user(none_user)) + + @mock.patch(MODULE_PATH + '.tasks.IPBoardManager') + def test_update_all_groups(self, manager): + service = self.service() + service.update_all_groups() + # Check member and blue user have groups updated + self.assertTrue(manager.update_groups.called) + self.assertEqual(manager.update_groups.call_count, 2) + + def test_update_groups(self): + # Check member has Member group updated + with mock.patch(MODULE_PATH + '.tasks.IPBoardManager') as manager: + service = self.service() + member = User.objects.get(username=self.member) + service.update_groups(member) + self.assertTrue(manager.update_groups.called) + + # Check none user does not have groups updated + with mock.patch(MODULE_PATH + '.tasks.IPBoardManager') as manager: + service = self.service() + none_user = User.objects.get(username=self.none_user) + service.update_groups(none_user) + self.assertFalse(manager.update_groups.called) + + @mock.patch(MODULE_PATH + '.tasks.IPBoardManager') + def test_validate_user(self, manager): + service = self.service() + # Test member is not deleted + member = User.objects.get(username=self.member) + service.validate_user(member) + self.assertTrue(User.objects.get(username=self.member).ipboard) + + # Test none user is deleted + none_user = User.objects.get(username=self.none_user) + IpboardUser.objects.create(user=none_user, username='none_user') + service.validate_user(none_user) + self.assertTrue(manager.disable_user.called) + with self.assertRaises(ObjectDoesNotExist): + none_ipboard = User.objects.get(username=self.none_user).ipboard + + @mock.patch(MODULE_PATH + '.tasks.IPBoardManager') + def test_delete_user(self, manager): + member = User.objects.get(username=self.member) + + service = self.service() + result = service.delete_user(member) + + self.assertTrue(result) + self.assertTrue(manager.disable_user.called) + with self.assertRaises(ObjectDoesNotExist): + Ipboard_user = User.objects.get(username=self.member).ipboard + + def test_render_services_ctrl(self): + service = self.service() + member = User.objects.get(username=self.member) + request = RequestFactory().get('/en/services/') + request.user = member + + response = service.render_services_ctrl(request) + self.assertTemplateUsed(service.service_ctrl_template) + self.assertIn('/ipboard/set_password/', response) + self.assertIn('/ipboard/reset_password/', response) + self.assertIn('/ipboard/deactivate/', response) + + # Test register becomes available + member.ipboard.delete() + member = User.objects.get(username=self.member) + request.user = member + response = service.render_services_ctrl(request) + self.assertIn('/ipboard/activate/', response) + + +class IpboardViewsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.member.set_password('password') + self.member.email = 'auth_member@example.com' + self.member.save() + AuthUtils.add_main_character(self.member, 'auth_member', '12345', corp_id='111', corp_name='Test Corporation') + + def login(self): + self.client.login(username=self.member.username, password='password') + + @mock.patch(MODULE_PATH + '.views.IPBoardManager') + def test_activate(self, manager): + self.login() + expected_username = 'auth_member' + expected_password = 'abc123' + manager.add_user.return_value = (expected_username, expected_password) + response = self.client.get(urls.reverse('auth_activate_ipboard'), follow=False) + self.assertEqual(response.status_code, 200) + self.assertContains(response, expected_username) + self.assertContains(response, expected_password) + self.assertTrue(manager.add_user.called) + args, kwargs = manager.add_user.call_args + self.assertEqual(args[0], 'auth_member') # Character name + self.assertEqual(args[1], self.member.email) + self.assertEqual(self.member.ipboard.username, expected_username) + + @mock.patch(MODULE_PATH + '.tasks.IPBoardManager') + def test_deactivate(self, manager): + self.login() + IpboardUser.objects.create(user=self.member, username='12345') + manager.disable_user.return_value = True + + response = self.client.get(urls.reverse('auth_deactivate_ipboard')) + + self.assertTrue(manager.disable_user.called) + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + with self.assertRaises(ObjectDoesNotExist): + ipboard_user = User.objects.get(pk=self.member.pk).ipboard + + @mock.patch(MODULE_PATH + '.views.IPBoardManager') + def test_set_password(self, manager): + self.login() + IpboardUser.objects.create(user=self.member, username='12345') + expected_password = 'password' + manager.update_user_password.return_value = expected_password + + response = self.client.post(urls.reverse('auth_set_ipboard_password'), data={'password': expected_password}) + + self.assertTrue(manager.update_user_password.called) + args, kwargs = manager.update_user_password.call_args + self.assertEqual(kwargs['plain_password'], expected_password) + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + + @mock.patch(MODULE_PATH + '.views.IPBoardManager') + def test_reset_password(self, manager): + self.login() + IpboardUser.objects.create(user=self.member, username='12345') + + response = self.client.get(urls.reverse('auth_reset_ipboard_password')) + + self.assertTrue(manager.update_user_password.called) + self.assertTemplateUsed(response, 'registered/service_credentials.html') + + +class IpboardManagerTestCase(TestCase): + def setUp(self): + self.manager = IPBoardManager + + def test_generate_random_password(self): + password = self.manager._IPBoardManager__generate_random_pass() + + self.assertEqual(len(password), 16) + self.assertIsInstance(password, type('')) + + def test_gen_pwhash(self): + pwhash = self.manager._gen_pwhash('test') + + self.assertEqual(pwhash, '098f6bcd4621d373cade4e832627b4f6') diff --git a/services/modules/ipboard/urls.py b/services/modules/ipboard/urls.py new file mode 100644 index 00000000..78f1d70a --- /dev/null +++ b/services/modules/ipboard/urls.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +from django.conf.urls import url, include + +from . import views + +module_urls = [ + # Ipboard service control + url(r'^activate/$', views.activate_ipboard_forum, name='auth_activate_ipboard'), + url(r'^deactivate/$', views.deactivate_ipboard_forum, name='auth_deactivate_ipboard'), + url(r'^reset_password/$', views.reset_ipboard_password, name='auth_reset_ipboard_password'), + url(r'^set_password/$', views.set_ipboard_password, name='auth_set_ipboard_password'), +] + +urlpatterns = [ + url(r'^ipboard/', include(module_urls)) +] diff --git a/services/modules/ipboard/views.py b/services/modules/ipboard/views.py new file mode 100644 index 00000000..6033ebf8 --- /dev/null +++ b/services/modules/ipboard/views.py @@ -0,0 +1,110 @@ +from __future__ import unicode_literals + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect + +from authentication.decorators import members_and_blues +from services.forms import ServicePasswordForm +from eveonline.managers import EveManager + +from .manager import IPBoardManager +from .tasks import IpboardTasks +from .models import IpboardUser + +import logging + +logger = logging.getLogger(__name__) + + +@login_required +@members_and_blues() +def activate_ipboard_forum(request): + logger.debug("activate_ipboard_forum called by user %s" % request.user) + character = EveManager.get_main_character(request.user) + logger.debug("Adding ipboard user for user %s with main character %s" % (request.user, character)) + result = IPBoardManager.add_user(character.character_name, request.user.email) + if result[0] != "": + ipboard_user = IpboardUser() + ipboard_user.user = request.user + ipboard_user.username = result[0] + ipboard_user.save() + logger.debug("Updated authserviceinfo for user %s with ipboard credentials. Updating groups." % request.user) + IpboardTasks.update_groups.delay(request.user.pk) + logger.info("Successfully activated ipboard for user %s" % request.user) + messages.success(request, 'Activated IPBoard account.') + credentials = { + 'username': result[0], + 'password': result[1], + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'IPBoard'}) + else: + logger.error("Unsuccessful attempt to activate ipboard for user %s" % request.user) + messages.error(request, 'An error occurred while processing your IPBoard account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def deactivate_ipboard_forum(request): + logger.debug("deactivate_ipboard_forum called by user %s" % request.user) + # false we failed + if IpboardTasks.delete_user(request.user): + logger.info("Successfully deactivated ipboard for user %s" % request.user) + messages.success(request, 'Deactivated IPBoard account.') + else: + logger.error("Unsuccessful attempt to deactviate ipboard for user %s" % request.user) + messages.error(request, 'An error occurred while processing your IPBoard account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def set_ipboard_password(request): + logger.debug("set_ipboard_password called by user %s" % request.user) + error = None + if request.method == 'POST': + logger.debug("Received POST request with form.") + form = ServicePasswordForm(request.POST) + logger.debug("Form is valid: %s" % form.is_valid()) + if form.is_valid() and IpboardTasks.has_account(request.user): + password = form.cleaned_data['password'] + logger.debug("Form contains password of length %s" % len(password)) + result = IPBoardManager.update_user_password(request.user.ipboard.username, request.user.email, + plain_password=password) + if result != "": + logger.info("Successfully set IPBoard password for user %s" % request.user) + messages.success(request, 'Set IPBoard password.') + else: + logger.error("Failed to install custom ipboard password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your IPBoard account.') + return redirect("auth_services") + else: + logger.debug("Request is not type POST - providing empty form.") + form = ServicePasswordForm() + + logger.debug("Rendering form for user %s" % request.user) + context = {'form': form, 'service': 'IPBoard', 'error': error} + return render(request, 'registered/service_password.html', context=context) + + +@login_required +@members_and_blues() +def reset_ipboard_password(request): + logger.debug("reset_ipboard_password called by user %s" % request.user) + if IpboardTasks.has_account(request.user): + result = IPBoardManager.update_user_password(request.user.ipboard.username, request.user.email) + if result != "": + logger.info("Successfully reset ipboard password for user %s" % request.user) + messages.success(request, 'Reset IPBoard password.') + credentials = { + 'username': request.user.ipboard.username, + 'password': result, + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'IPBoard'}) + + logger.error("Unsuccessful attempt to reset ipboard password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your IPBoard account.') + return redirect("auth_services") diff --git a/services/modules/ips4/__init__.py b/services/modules/ips4/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/ips4/admin.py b/services/modules/ips4/admin.py new file mode 100644 index 00000000..bb25bea8 --- /dev/null +++ b/services/modules/ips4/admin.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from django.contrib import admin +from .models import Ips4User + + +class Ips4UserAdmin(admin.ModelAdmin): + list_display = ('user', 'username', 'id') + search_fields = ('user__username', 'username', 'id') + +admin.site.register(Ips4User, Ips4UserAdmin) diff --git a/services/modules/ips4/apps.py b/services/modules/ips4/apps.py new file mode 100644 index 00000000..40047d31 --- /dev/null +++ b/services/modules/ips4/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class Ips4ServiceConfig(AppConfig): + name = 'ips4' diff --git a/services/modules/ips4/auth_hooks.py b/services/modules/ips4/auth_hooks.py new file mode 100644 index 00000000..a29b4c55 --- /dev/null +++ b/services/modules/ips4/auth_hooks.py @@ -0,0 +1,53 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.template.loader import render_to_string + +from services.hooks import ServicesHook +from alliance_auth import hooks + +from .urls import urlpatterns +from .tasks import Ips4Tasks + + +class Ips4Service(ServicesHook): + def __init__(self): + ServicesHook.__init__(self) + self.name = 'ips4' + self.urlpatterns = urlpatterns + self.service_url = settings.IPS4_URL + + @property + def title(self): + return 'IPS4' + + def service_enabled_members(self): + return settings.ENABLE_AUTH_IPS4 or False + + def service_enabled_blues(self): + return settings.ENABLE_BLUE_IPS4 or False + + def render_services_ctrl(self, request): + """ + Example for rendering the service control panel row + You can override the default template and create a + custom one if you wish. + :param request: + :return: + """ + urls = self.Urls() + urls.auth_activate = 'auth_activate_ips4' + urls.auth_deactivate = 'auth_deactivate_ips4' + urls.auth_reset_password = 'auth_reset_ips4_password' + urls.auth_set_password = 'auth_set_ips4_password' + return render_to_string(self.service_ctrl_template, { + 'service_name': self.title, + 'urls': urls, + 'service_url': self.service_url, + 'username': request.user.ips4.username if Ips4Tasks.has_account(request.user) else '' + }, request=request) + + +@hooks.register('services_hook') +def register_service(): + return Ips4Service() diff --git a/services/managers/ips4_manager.py b/services/modules/ips4/manager.py similarity index 67% rename from services/managers/ips4_manager.py rename to services/modules/ips4/manager.py index 1fd59b5c..2e7468c6 100644 --- a/services/managers/ips4_manager.py +++ b/services/modules/ips4/manager.py @@ -1,6 +1,8 @@ from __future__ import unicode_literals import logging -import os +import random +import string +import re from django.db import connections from passlib.hash import bcrypt @@ -16,18 +18,16 @@ class Ips4Manager: MEMBER_GROUP_ID = 3 - @staticmethod - def add_user(username, email): + @classmethod + def add_user(cls, username, email): logger.debug("Adding new IPS4 user %s" % username) - plain_password = Ips4Manager.__generate_random_pass() - hash = bcrypt.encrypt(plain_password, rounds=13) - hash_result = hash - rounds_striped = hash_result.strip('$2a$13$') - salt = rounds_striped[:22] - group = Ips4Manager.MEMBER_GROUP_ID + plain_password = cls.__generate_random_pass() + hash = cls._gen_pwhash(plain_password) + salt = cls._get_salt(hash) + group = cls.MEMBER_GROUP_ID cursor = connections['ips4'].cursor() - cursor.execute(Ips4Manager.SQL_ADD_USER, [username, email, hash, salt, group]) - member_id = Ips4Manager.get_user_id(username) + cursor.execute(cls.SQL_ADD_USER, [username, email, hash, salt, group]) + member_id = cls.get_user_id(username) return username, plain_password, member_id @staticmethod @@ -44,7 +44,17 @@ class Ips4Manager: @staticmethod def __generate_random_pass(): - return os.urandom(8).encode('hex') + return ''.join([random.choice(string.ascii_letters + string.digits) for n in range(16)]) + + @staticmethod + def _gen_pwhash(password): + return bcrypt.encrypt(password.encode('utf-8'), rounds=13) + + @staticmethod + def _get_salt(pw_hash): + search = re.compile(r"^\$2[a-z]?\$([0-9]+)\$(.{22})(.{31})$") + match = re.match(search, pw_hash) + return match.group(2) @staticmethod def delete_user(id): @@ -58,17 +68,15 @@ class Ips4Manager: logger.exception("Failed to delete IPS4 user id %s" % id) return False - @staticmethod - def update_user_password(username): + @classmethod + def update_user_password(cls, username): logger.debug("Updating IPS4 user id %s password" % id) - if Ips4Manager.check_user(username): + if cls.check_user(username): plain_password = Ips4Manager.__generate_random_pass() - hash = bcrypt.encrypt(plain_password, rounds=13) - hash_result = hash - rounds_striped = hash_result.strip('$2a$13$') - salt = rounds_striped[:22] + hash = cls._gen_pwhash(plain_password) + salt = cls._get_salt(hash) cursor = connections['ips4'].cursor() - cursor.execute(Ips4Manager.SQL_UPDATE_PASSWORD, [hash, salt, username]) + cursor.execute(cls.SQL_UPDATE_PASSWORD, [hash, salt, username]) return plain_password else: logger.error("Unable to update ips4 user %s password" % username) @@ -86,16 +94,14 @@ class Ips4Manager: logger.debug("User %s not found on IPS4" % username) return False - @staticmethod - def update_custom_password(username, plain_password): + @classmethod + def update_custom_password(cls, username, plain_password): logger.debug("Updating IPS4 user id %s password" % id) - if Ips4Manager.check_user(username): - hash = bcrypt.encrypt(plain_password, rounds=13) - hash_result = hash - rounds_striped = hash_result.strip('$2a$13$') - salt = rounds_striped[:22] + if cls.check_user(username): + hash = cls._gen_pwhash(plain_password) + salt = cls._get_salt(hash) cursor = connections['ips4'].cursor() - cursor.execute(Ips4Manager.SQL_UPDATE_PASSWORD, [hash, salt, username]) + cursor.execute(cls.SQL_UPDATE_PASSWORD, [hash, salt, username]) return plain_password else: logger.error("Unable to update ips4 user %s password" % username) diff --git a/services/modules/ips4/migrations/0001_initial.py b/services/modules/ips4/migrations/0001_initial.py new file mode 100644 index 00000000..1d3826f8 --- /dev/null +++ b/services/modules/ips4/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 03:27 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='Ips4User', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='ips4', serialize=False, to=settings.AUTH_USER_MODEL)), + ('username', models.CharField(max_length=254)), + ('id', models.CharField(max_length=254)), + ], + ), + ] diff --git a/services/modules/ips4/migrations/__init__.py b/services/modules/ips4/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/ips4/models.py b/services/modules/ips4/models.py new file mode 100644 index 00000000..445ec19a --- /dev/null +++ b/services/modules/ips4/models.py @@ -0,0 +1,17 @@ +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible +from django.contrib.auth.models import User +from django.db import models + + +@python_2_unicode_compatible +class Ips4User(models.Model): + user = models.OneToOneField(User, + primary_key=True, + on_delete=models.CASCADE, + related_name='ips4') + username = models.CharField(max_length=254) + id = models.CharField(max_length=254) + + def __str__(self): + return self.username diff --git a/services/modules/ips4/tasks.py b/services/modules/ips4/tasks.py new file mode 100644 index 00000000..73fb6aa8 --- /dev/null +++ b/services/modules/ips4/tasks.py @@ -0,0 +1,43 @@ +from __future__ import unicode_literals, absolute_import + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist + +from .manager import Ips4Manager +from .models import Ips4User + +import logging + +logger = logging.getLogger(__name__) + + +class Ips4Tasks: + def __init__(self): + pass + + @classmethod + def delete_user(cls, user): + logging.debug("Attempting to delete IPS4 account for %s" % user) + if cls.has_account(user) and Ips4Manager.delete_user(user.ips4.id): + user.ips4.delete() + logger.info("Successfully deactivated IPS4 for user %s" % user) + return True + return False + + @staticmethod + def has_account(user): + try: + return user.ips4.id != '' + except ObjectDoesNotExist: + return False + + @staticmethod + def disable(): + if settings.ENABLE_AUTH_IPS4: + logger.warn( + "ENABLE_AUTH_IPS4 still True, after disabling users will still be able to create IPS4 accounts") + if settings.ENABLE_BLUE_IPS4: + logger.warn( + "ENABLE_BLUE_IPS4 still True, after disabling blues will still be able to create IPS4 accounts") + logging.debug("Deleting all IPS4 users") + Ips4User.objects.all().delete() diff --git a/services/modules/ips4/tests.py b/services/modules/ips4/tests.py new file mode 100644 index 00000000..aafe1781 --- /dev/null +++ b/services/modules/ips4/tests.py @@ -0,0 +1,164 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase, RequestFactory +from django.conf import settings +from django import urls +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist + +from alliance_auth.tests.auth_utils import AuthUtils + +from .auth_hooks import Ips4Service +from .models import Ips4User +from .tasks import Ips4Tasks + +MODULE_PATH = 'services.modules.ips4' + + +class Ips4HooksTestCase(TestCase): + def setUp(self): + self.member = 'member_user' + member = AuthUtils.create_member(self.member) + Ips4User.objects.create(user=member, id='12345', username=self.member) + self.blue = 'blue_user' + blue = AuthUtils.create_blue(self.blue) + Ips4User.objects.create(user=blue, id='67891', username=self.blue) + self.none_user = 'none_user' + none_user = AuthUtils.create_user(self.none_user) + self.service = Ips4Service + + def test_has_account(self): + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(Ips4Tasks.has_account(member)) + self.assertTrue(Ips4Tasks.has_account(blue)) + self.assertFalse(Ips4Tasks.has_account(none_user)) + + def test_service_enabled(self): + service = self.service() + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(service.service_enabled_members()) + self.assertTrue(service.service_enabled_blues()) + + self.assertEqual(service.service_active_for_user(member), settings.ENABLE_AUTH_IPS4) + self.assertEqual(service.service_active_for_user(blue), settings.ENABLE_BLUE_IPS4) + self.assertFalse(service.service_active_for_user(none_user)) + + def test_render_services_ctrl(self): + service = self.service() + member = User.objects.get(username=self.member) + request = RequestFactory().get('/en/services/') + request.user = member + + response = service.render_services_ctrl(request) + self.assertTemplateUsed(service.service_ctrl_template) + self.assertIn(urls.reverse('auth_set_ips4_password'), response) + self.assertIn(urls.reverse('auth_reset_ips4_password'), response) + self.assertIn(urls.reverse('auth_deactivate_ips4'), response) + + # Test register becomes available + member.ips4.delete() + member = User.objects.get(username=self.member) + request.user = member + response = service.render_services_ctrl(request) + self.assertIn(urls.reverse('auth_activate_ips4'), response) + + +class Ips4ViewsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.member.set_password('password') + self.member.email = 'auth_member@example.com' + self.member.save() + AuthUtils.add_main_character(self.member, 'auth_member', '12345', corp_id='111', corp_name='Test Corporation') + + def login(self): + self.client.login(username=self.member.username, password='password') + + @mock.patch(MODULE_PATH + '.views.Ips4Manager') + def test_activate(self, manager): + self.login() + expected_username = 'auth_member' + expected_password = 'password' + expected_id = '1234' + + manager.add_user.return_value = (expected_username, expected_password, expected_id) + + response = self.client.get(urls.reverse('auth_activate_ips4'), follow=False) + + self.assertTrue(manager.add_user.called) + args, kwargs = manager.add_user.call_args + self.assertEqual(args[0], expected_username) + self.assertEqual(args[1], self.member.email) + + self.assertTemplateUsed(response, 'registered/service_credentials.html') + self.assertContains(response, expected_username) + self.assertContains(response, expected_password) + + @mock.patch(MODULE_PATH + '.tasks.Ips4Manager') + def test_deactivate(self, manager): + self.login() + Ips4User.objects.create(user=self.member, username='12345', id='1234') + manager.delete_user.return_value = True + + response = self.client.get(urls.reverse('auth_deactivate_ips4')) + + self.assertTrue(manager.delete_user.called) + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + with self.assertRaises(ObjectDoesNotExist): + ips4_user = User.objects.get(pk=self.member.pk).ips4 + + @mock.patch(MODULE_PATH + '.views.Ips4Manager') + def test_set_password(self, manager): + self.login() + Ips4User.objects.create(user=self.member, username='12345', id='1234') + expected_password = 'password' + manager.update_user_password.return_value = expected_password + + response = self.client.post(urls.reverse('auth_set_ips4_password'), data={'password': expected_password}) + + self.assertTrue(manager.update_custom_password.called) + args, kwargs = manager.update_custom_password.call_args + self.assertEqual(kwargs['plain_password'], expected_password) + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + + @mock.patch(MODULE_PATH + '.views.Ips4Manager') + def test_reset_password(self, manager): + self.login() + Ips4User.objects.create(user=self.member, username='12345', id='1234') + + response = self.client.get(urls.reverse('auth_reset_ips4_password')) + + self.assertTrue(manager.update_user_password.called) + self.assertTemplateUsed(response, 'registered/service_credentials.html') + + +class Ips4ManagerTestCase(TestCase): + def setUp(self): + from .manager import Ips4Manager + self.manager = Ips4Manager + + def test_generate_random_password(self): + password = self.manager._Ips4Manager__generate_random_pass() + + self.assertEqual(len(password), 16) + self.assertIsInstance(password, type('')) + + def test_gen_pwhash(self): + pwhash = self.manager._gen_pwhash('test') + salt = self.manager._get_salt(pwhash) + + self.assertIsInstance(pwhash, str) + self.assertGreaterEqual(len(pwhash), 59) + self.assertIsInstance(salt, str) + self.assertEqual(len(salt), 22) diff --git a/services/modules/ips4/urls.py b/services/modules/ips4/urls.py new file mode 100644 index 00000000..ac1a86a9 --- /dev/null +++ b/services/modules/ips4/urls.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +from django.conf.urls import url, include + +from . import views + +module_urls = [ + # IPS4 Service Control + url(r'^activate/$', views.activate_ips4, name='auth_activate_ips4'), + url(r'^deactivate/$', views.deactivate_ips4, name='auth_deactivate_ips4'), + url(r'^reset_password/$', views.reset_ips4_password, name='auth_reset_ips4_password'), + url(r'^set_password/$', views.set_ips4_password, name='auth_set_ips4_password'), +] + +urlpatterns = [ + url(r'^ips4/', include(module_urls)) +] diff --git a/services/modules/ips4/views.py b/services/modules/ips4/views.py new file mode 100644 index 00000000..a1d862d8 --- /dev/null +++ b/services/modules/ips4/views.py @@ -0,0 +1,107 @@ +from __future__ import unicode_literals + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect + +from authentication.decorators import members_and_blues +from eveonline.managers import EveManager +from services.forms import ServicePasswordForm + +from .manager import Ips4Manager +from .models import Ips4User +from .tasks import Ips4Tasks + +import logging + +logger = logging.getLogger(__name__) + + +@login_required +@members_and_blues() +def activate_ips4(request): + logger.debug("activate_ips4 called by user %s" % request.user) + character = EveManager.get_main_character(request.user) + logger.debug("Adding IPS4 user for user %s with main character %s" % (request.user, character)) + result = Ips4Manager.add_user(character.character_name, request.user.email) + # if empty we failed + if result[0] != "" and not Ips4Tasks.has_account(request.user): + ips_user = Ips4User.objects.create(user=request.user, id=result[2], username=result[0]) + logger.debug("Updated authserviceinfo for user %s with IPS4 credentials." % request.user) + # update_ips4_groups.delay(request.user.pk) + logger.info("Successfully activated IPS4 for user %s" % request.user) + messages.success(request, 'Activated IPSuite4 account.') + credentials = { + 'username': result[0], + 'password': result[1], + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'IPSuite4'}) + else: + logger.error("Unsuccessful attempt to activate IPS4 for user %s" % request.user) + messages.error(request, 'An error occurred while processing your IPSuite4 account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def reset_ips4_password(request): + logger.debug("reset_ips4_password called by user %s" % request.user) + if Ips4Tasks.has_account(request.user): + result = Ips4Manager.update_user_password(request.user.ips4.username) + # false we failed + if result != "": + logger.info("Successfully reset IPS4 password for user %s" % request.user) + messages.success(request, 'Reset IPSuite4 password.') + credentials = { + 'username': request.user.ips4.username, + 'password': result, + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'IPSuite4'}) + + logger.error("Unsuccessful attempt to reset IPS4 password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your IPSuite4 account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def set_ips4_password(request): + logger.debug("set_ips4_password called by user %s" % request.user) + if request.method == 'POST': + logger.debug("Received POST request with form.") + form = ServicePasswordForm(request.POST) + logger.debug("Form is valid: %s" % form.is_valid()) + if form.is_valid() and Ips4Tasks.has_account(request.user): + password = form.cleaned_data['password'] + logger.debug("Form contains password of length %s" % len(password)) + result = Ips4Manager.update_custom_password(request.user.ips4.username, plain_password=password) + if result != "": + logger.info("Successfully set IPS4 password for user %s" % request.user) + messages.success(request, 'Set IPSuite4 password.') + else: + logger.error("Failed to install custom IPS4 password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your IPSuite4 account.') + return redirect('auth_services') + else: + logger.debug("Request is not type POST - providing empty form.") + form = ServicePasswordForm() + + logger.debug("Rendering form for user %s" % request.user) + context = {'form': form, 'service': 'IPS4'} + return render(request, 'registered/service_password.html', context=context) + + +@login_required +@members_and_blues() +def deactivate_ips4(request): + logger.debug("deactivate_ips4 called by user %s" % request.user) + if Ips4Tasks.delete_user(request.user): + logger.info("Successfully deactivated IPS4 for user %s" % request.user) + messages.success(request, 'Deactivated IPSuite4 account.') + else: + logger.error("Unsuccessful attempt to deactivate IPS4 for user %s" % request.user) + messages.error(request, 'An error occurred while processing your IPSuite4 account.') + return redirect("auth_services") + diff --git a/services/modules/market/__init__.py b/services/modules/market/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/market/admin.py b/services/modules/market/admin.py new file mode 100644 index 00000000..6b7ca8d2 --- /dev/null +++ b/services/modules/market/admin.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from django.contrib import admin +from .models import MarketUser + + +class MarketUserAdmin(admin.ModelAdmin): + list_display = ('user', 'username') + search_fields = ('user__username', 'username') + +admin.site.register(MarketUser, MarketUserAdmin) diff --git a/services/modules/market/apps.py b/services/modules/market/apps.py new file mode 100644 index 00000000..ff750cb7 --- /dev/null +++ b/services/modules/market/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class MarketServiceConfig(AppConfig): + name = 'market' diff --git a/services/modules/market/auth_hooks.py b/services/modules/market/auth_hooks.py new file mode 100644 index 00000000..8a680e2b --- /dev/null +++ b/services/modules/market/auth_hooks.py @@ -0,0 +1,59 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.template.loader import render_to_string + +from services.hooks import ServicesHook +from alliance_auth import hooks + +from .urls import urlpatterns +from .tasks import MarketTasks + +import logging + +logger = logging.getLogger(__name__) + + +class MarketService(ServicesHook): + def __init__(self): + ServicesHook.__init__(self) + self.name = 'market' + self.urlpatterns = urlpatterns + self.service_url = settings.MARKET_URL + + @property + def title(self): + return "Alliance Market" + + def delete_user(self, user, notify_user=False): + logger.debug('Deleting user %s %s account' % (user, self.name)) + return MarketTasks.delete_user(user, notify_user=notify_user) + + def validate_user(self, user): + logger.debug('Validating user %s %s account' % (user, self.name)) + if MarketTasks.has_account(user) and self.service_active_for_user(user): + self.delete_user(user) + + def service_enabled_members(self): + return settings.ENABLE_AUTH_MARKET or False + + def service_enabled_blues(self): + return settings.ENABLE_BLUE_MARKET or False + + def render_services_ctrl(self, request): + urls = self.Urls() + urls.auth_activate = 'auth_activate_market' + urls.auth_deactivate = 'auth_deactivate_market' + urls.auth_reset_password = 'auth_reset_market_password' + urls.auth_set_password = 'auth_set_market_password' + return render_to_string(self.service_ctrl_template, { + 'service_name': self.title, + 'urls': urls, + 'service_url': self.service_url, + 'username': request.user.market.username if MarketTasks.has_account(request.user) else '' + }, request=request) + + +@hooks.register('services_hook') +def register_service(): + return MarketService() diff --git a/services/managers/market_manager.py b/services/modules/market/manager.py similarity index 58% rename from services/managers/market_manager.py rename to services/modules/market/manager.py index c1958fbb..5938add2 100644 --- a/services/managers/market_manager.py +++ b/services/modules/market/manager.py @@ -1,6 +1,8 @@ from __future__ import unicode_literals import logging -import os +import random +import string +import re from django.db import connections from passlib.hash import bcrypt @@ -10,7 +12,7 @@ from passlib.hash import bcrypt logger = logging.getLogger(__name__) -class marketManager: +class MarketManager: def __init__(self): pass @@ -32,13 +34,23 @@ class marketManager: @staticmethod def __generate_random_pass(): - return os.urandom(8).encode('hex') + return ''.join([random.choice(string.ascii_letters + string.digits) for n in range(16)]) @staticmethod - def check_username(username): + def _gen_pwhash(password): + return bcrypt.encrypt(password.encode('utf-8'), rounds=13) + + @staticmethod + def _get_salt(pw_hash): + search = re.compile(r"^\$2[a-z]?\$([0-9]+)\$(.{22})(.{31})$") + match = re.match(search, pw_hash) + return match.group(2) + + @classmethod + def check_username(cls, username): logger.debug("Checking alliance market username %s" % username) cursor = connections['market'].cursor() - cursor.execute(marketManager.SQL_CHECK_USERNAME, [marketManager.__santatize_username(username)]) + cursor.execute(cls.SQL_CHECK_USERNAME, [cls.__santatize_username(username)]) row = cursor.fetchone() if row: logger.debug("Found user %s on alliance market" % username) @@ -46,11 +58,11 @@ class marketManager: logger.debug("User %s not found on alliance market" % username) return False - @staticmethod - def check_user_email(username, email): + @classmethod + def check_user_email(cls, username, email): logger.debug("Checking if alliance market email exists for user %s" % username) cursor = connections['market'].cursor() - cursor.execute(marketManager.SQL_CHECK_EMAIL, [email]) + cursor.execute(cls.SQL_CHECK_EMAIL, [email]) row = cursor.fetchone() if row: logger.debug("Found user %s email address on alliance market" % username) @@ -58,21 +70,19 @@ class marketManager: logger.debug("User %s email address not found on alliance market" % username) return False - @staticmethod - def add_user(username, email, characterid, charactername): + @classmethod + def add_user(cls, username, email, characterid, charactername): logger.debug("Adding new market user %s" % username) - plain_password = marketManager.__generate_random_pass() - hash = bcrypt.encrypt(plain_password, rounds=13) - hash_result = hash - rounds_striped = hash_result.strip('$2a$13$') - salt = rounds_striped[:22] - username_clean = marketManager.__santatize_username(username) - if not marketManager.check_username(username): - if not marketManager.check_user_email(username, email): + plain_password = cls.__generate_random_pass() + hash = cls._gen_pwhash(plain_password) + salt = cls._get_salt(hash) + username_clean = cls.__santatize_username(username) + if not cls.check_username(username): + if not cls.check_user_email(username, email): try: logger.debug("Adding user %s to alliance market" % username) cursor = connections['market'].cursor() - cursor.execute(marketManager.SQL_ADD_USER, [username_clean, username_clean, email, email, salt, + cursor.execute(cls.SQL_ADD_USER, [username_clean, username_clean, email, email, salt, hash, characterid, charactername]) return username_clean, plain_password except: @@ -80,65 +90,59 @@ class marketManager: return "", "" else: logger.debug("Alliance market email %s already exists Updating instead" % email) - username_clean, password = marketManager.update_user_info(username) + username_clean, password = cls.update_user_info(username) return username_clean, password else: logger.debug("Alliance market username %s already exists Updating instead" % username) - username_clean, password = marketManager.update_user_info(username) + username_clean, password = cls.update_user_info(username) return username_clean, password - @staticmethod - def disable_user(username): + @classmethod + def disable_user(cls, username): logger.debug("Disabling alliance market user %s " % username) cursor = connections['market'].cursor() - cursor.execute(marketManager.SQL_DISABLE_USER, [username]) + cursor.execute(cls.SQL_DISABLE_USER, [username]) return True - @staticmethod - def update_custom_password(username, plain_password): + @classmethod + def update_custom_password(cls, username, plain_password): logger.debug("Updating alliance market user %s password" % username) - if marketManager.check_username(username): - username_clean = marketManager.__santatize_username(username) - hash = bcrypt.encrypt(plain_password, rounds=13) - hash_result = hash - rounds_striped = hash_result.strip('$2a$13$') - salt = rounds_striped[:22] + if cls.check_username(username): + username_clean = cls.__santatize_username(username) + hash = cls._gen_pwhash(plain_password) + salt = cls._get_salt(hash) cursor = connections['market'].cursor() - cursor.execute(marketManager.SQL_UPDATE_PASSWORD, [hash, salt, username_clean]) + cursor.execute(cls.SQL_UPDATE_PASSWORD, [hash, salt, username_clean]) return plain_password else: logger.error("Unable to update alliance market user %s password" % username) return "" - @staticmethod - def update_user_password(username): + @classmethod + def update_user_password(cls, username): logger.debug("Updating alliance market user %s password" % username) - if marketManager.check_username(username): - username_clean = marketManager.__santatize_username(username) - plain_password = marketManager.__generate_random_pass() - hash = bcrypt.encrypt(plain_password, rounds=13) - hash_result = hash - rounds_striped = hash_result.strip('$2a$13$') - salt = rounds_striped[:22] + if cls.check_username(username): + username_clean = cls.__santatize_username(username) + plain_password = cls.__generate_random_pass() + hash = cls._gen_pwhash(plain_password) + salt = cls._get_salt(hash) cursor = connections['market'].cursor() - cursor.execute(marketManager.SQL_UPDATE_PASSWORD, [hash, salt, username_clean]) + cursor.execute(cls.SQL_UPDATE_PASSWORD, [hash, salt, username_clean]) return plain_password else: logger.error("Unable to update alliance market user %s password" % username) return "" - @staticmethod - def update_user_info(username): + @classmethod + def update_user_info(cls, username): logger.debug("Updating alliance market user %s" % username) try: - username_clean = marketManager.__santatize_username(username) - plain_password = marketManager.__generate_random_pass() - hash = bcrypt.encrypt(plain_password, rounds=13) - hash_result = hash - rounds_striped = hash_result.strip('$2a$13$') - salt = rounds_striped[:22] + username_clean = cls.__santatize_username(username) + plain_password = cls.__generate_random_pass() + hash = cls._gen_pwhash(plain_password) + salt = cls._get_salt(hash) cursor = connections['market'].cursor() - cursor.execute(marketManager.SQL_UPDATE_USER, [hash, salt, username_clean]) + cursor.execute(cls.SQL_UPDATE_USER, [hash, salt, username_clean]) return username_clean, plain_password except: logger.debug("Alliance market update user failed for %s" % username) diff --git a/services/modules/market/migrations/0001_initial.py b/services/modules/market/migrations/0001_initial.py new file mode 100644 index 00000000..1e306c9f --- /dev/null +++ b/services/modules/market/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 03:27 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='MarketUser', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='market', serialize=False, to=settings.AUTH_USER_MODEL)), + ('username', models.CharField(max_length=254)), + ], + ), + ] diff --git a/services/modules/market/migrations/__init__.py b/services/modules/market/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/market/models.py b/services/modules/market/models.py new file mode 100644 index 00000000..1edba722 --- /dev/null +++ b/services/modules/market/models.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible +from django.contrib.auth.models import User +from django.db import models + + +@python_2_unicode_compatible +class MarketUser(models.Model): + user = models.OneToOneField(User, + primary_key=True, + on_delete=models.CASCADE, + related_name='market') + username = models.CharField(max_length=254) + + def __str__(self): + return self.username diff --git a/services/modules/market/tasks.py b/services/modules/market/tasks.py new file mode 100644 index 00000000..adb1160b --- /dev/null +++ b/services/modules/market/tasks.py @@ -0,0 +1,44 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from notifications import notify + +from .models import MarketUser +from .manager import MarketManager + + +import logging + +logger = logging.getLogger(__name__) + + +class MarketTasks: + def __init__(self): + pass + + @classmethod + def delete_user(cls, user, notify_user=False): + if cls.has_account(user): + logger.debug("User %s has a Market account %s. Deleting." % (user, user.market.username)) + if MarketManager.disable_user(user.market.username): + user.market.delete() + if notify_user: + notify(user, 'Alliance Market Account Disabled', level='danger') + return True + return False + + @staticmethod + def has_account(user): + try: + return user.market.username != '' + except ObjectDoesNotExist: + return False + + @staticmethod + def disable(): + if settings.ENABLE_AUTH_MARKET: + logger.warn("ENABLE_AUTH_MARKET still True, after disabling users will still be able to activate Market accounts") + if settings.ENABLE_BLUE_MARKET: + logger.warn("ENABLE_BLUE_MARKET still True, after disabling blues will still be able to activate Market accounts") + MarketUser.objects.all().delete() diff --git a/services/modules/market/tests.py b/services/modules/market/tests.py new file mode 100644 index 00000000..70c75d10 --- /dev/null +++ b/services/modules/market/tests.py @@ -0,0 +1,176 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase, RequestFactory +from django.conf import settings +from django import urls +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist + +from alliance_auth.tests.auth_utils import AuthUtils + +from .auth_hooks import MarketService +from .models import MarketUser +from .tasks import MarketTasks + +MODULE_PATH = 'services.modules.market' + + +class MarketHooksTestCase(TestCase): + def setUp(self): + self.member = 'member_user' + member = AuthUtils.create_member(self.member) + MarketUser.objects.create(user=member, username=self.member) + self.blue = 'blue_user' + blue = AuthUtils.create_blue(self.blue) + MarketUser.objects.create(user=blue, username=self.blue) + self.none_user = 'none_user' + none_user = AuthUtils.create_user(self.none_user) + self.service = MarketService + + def test_has_account(self): + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(MarketTasks.has_account(member)) + self.assertTrue(MarketTasks.has_account(blue)) + self.assertFalse(MarketTasks.has_account(none_user)) + + def test_service_enabled(self): + service = self.service() + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(service.service_enabled_members()) + self.assertTrue(service.service_enabled_blues()) + + self.assertEqual(service.service_active_for_user(member), settings.ENABLE_AUTH_MARKET) + self.assertEqual(service.service_active_for_user(blue), settings.ENABLE_BLUE_MARKET) + self.assertFalse(service.service_active_for_user(none_user)) + + @mock.patch(MODULE_PATH + '.tasks.MarketManager') + def test_delete_user(self, manager): + member = User.objects.get(username=self.member) + + service = self.service() + result = service.delete_user(member) + + self.assertTrue(result) + self.assertTrue(manager.disable_user.called) + with self.assertRaises(ObjectDoesNotExist): + market_user = User.objects.get(username=self.member).market + + def test_render_services_ctrl(self): + service = self.service() + member = User.objects.get(username=self.member) + request = RequestFactory().get('/en/services/') + request.user = member + + response = service.render_services_ctrl(request) + self.assertTemplateUsed(service.service_ctrl_template) + self.assertIn(urls.reverse('auth_set_market_password'), response) + self.assertIn(urls.reverse('auth_reset_market_password'), response) + self.assertIn(urls.reverse('auth_deactivate_market'), response) + + # Test register becomes available + member.market.delete() + member = User.objects.get(username=self.member) + request.user = member + response = service.render_services_ctrl(request) + self.assertIn(urls.reverse('auth_activate_market'), response) + + +class MarketViewsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.member.set_password('password') + self.member.email = 'auth_member@example.com' + self.member.save() + AuthUtils.add_main_character(self.member, 'auth_member', '12345', corp_id='111', corp_name='Test Corporation') + + def login(self): + self.client.login(username=self.member.username, password='password') + + @mock.patch(MODULE_PATH + '.views.MarketManager') + def test_activate(self, manager): + self.login() + expected_username = 'auth_member' + expected_password = 'password' + expected_id = '1234' + + manager.add_user.return_value = (expected_username, expected_password, expected_id) + + response = self.client.get(urls.reverse('auth_activate_market'), follow=False) + + self.assertTrue(manager.add_user.called) + args, kwargs = manager.add_user.call_args + self.assertEqual(args[0], expected_username) + self.assertEqual(args[1], self.member.email) + + self.assertTemplateUsed(response, 'registered/service_credentials.html') + self.assertContains(response, expected_username) + self.assertContains(response, expected_password) + + @mock.patch(MODULE_PATH + '.tasks.MarketManager') + def test_deactivate(self, manager): + self.login() + MarketUser.objects.create(user=self.member, username='12345') + manager.disable_user.return_value = True + + response = self.client.get(urls.reverse('auth_deactivate_market')) + + self.assertTrue(manager.disable_user.called) + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + with self.assertRaises(ObjectDoesNotExist): + market_user = User.objects.get(pk=self.member.pk).market + + @mock.patch(MODULE_PATH + '.views.MarketManager') + def test_set_password(self, manager): + self.login() + MarketUser.objects.create(user=self.member, username='12345') + expected_password = 'password' + manager.update_user_password.return_value = expected_password + + response = self.client.post(urls.reverse('auth_set_market_password'), data={'password': expected_password}) + + self.assertTrue(manager.update_custom_password.called) + args, kwargs = manager.update_custom_password.call_args + self.assertEqual(args[1], expected_password) + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + + @mock.patch(MODULE_PATH + '.views.MarketManager') + def test_reset_password(self, manager): + self.login() + MarketUser.objects.create(user=self.member, username='12345') + + response = self.client.get(urls.reverse('auth_reset_market_password')) + + self.assertTrue(manager.update_user_password.called) + self.assertTemplateUsed(response, 'registered/service_credentials.html') + + +class MarketManagerTestCase(TestCase): + def setUp(self): + from .manager import MarketManager + self.manager = MarketManager + + def test_generate_random_password(self): + password = self.manager._MarketManager__generate_random_pass() + + self.assertEqual(len(password), 16) + self.assertIsInstance(password, type('')) + + def test_gen_pwhash(self): + pwhash = self.manager._gen_pwhash('test') + salt = self.manager._get_salt(pwhash) + + self.assertIsInstance(pwhash, str) + self.assertGreaterEqual(len(pwhash), 59) + self.assertIsInstance(salt, str) + self.assertEqual(len(salt), 22) diff --git a/services/modules/market/urls.py b/services/modules/market/urls.py new file mode 100644 index 00000000..b0ebdea2 --- /dev/null +++ b/services/modules/market/urls.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +from django.conf.urls import url, include + +from . import views + +module_urls = [ + # Alliance Market Control + url(r'^activate/$', views.activate_market, name='auth_activate_market'), + url(r'^deactivate/$', views.deactivate_market, name='auth_deactivate_market'), + url(r'^reset_password/$', views.reset_market_password, name='auth_reset_market_password'), + url(r'^set_password/$', views.set_market_password, name='auth_set_market_password'), +] + +urlpatterns = [ + url(r'^market/', include(module_urls)) +] diff --git a/services/modules/market/views.py b/services/modules/market/views.py new file mode 100644 index 00000000..706bf5c0 --- /dev/null +++ b/services/modules/market/views.py @@ -0,0 +1,107 @@ +from __future__ import unicode_literals + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect + +from authentication.decorators import members_and_blues +from services.forms import ServicePasswordForm +from eveonline.managers import EveManager + +from .manager import MarketManager +from .models import MarketUser +from .tasks import MarketTasks + +import logging + +logger = logging.getLogger(__name__) + + +@login_required +@members_and_blues() +def activate_market(request): + logger.debug("activate_market called by user %s" % request.user) + character = EveManager.get_main_character(request.user) + if character is not None: + logger.debug("Adding market user for user %s with main character %s" % (request.user, character)) + result = MarketManager.add_user(character.character_name, request.user.email, character.character_id, + character.character_name) + # if empty we failed + if result[0] != "": + MarketUser.objects.create(user=request.user, username=result[0]) + logger.debug("Updated authserviceinfo for user %s with market credentials." % request.user) + logger.info("Successfully activated market for user %s" % request.user) + messages.success(request, 'Activated Alliance Market account.') + credentials = { + 'username': result[0], + 'password': result[1], + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'Alliance Market'}) + logger.error("Unsuccessful attempt to activate market for user %s" % request.user) + messages.error(request, 'An error occurred while processing your Alliance Market account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def deactivate_market(request): + logger.debug("deactivate_market called by user %s" % request.user) + # false we failed + if MarketTasks.delete_user(request.user): + logger.info("Successfully deactivated market for user %s" % request.user) + messages.success(request, 'Deactivated Alliance Market account.') + else: + logger.error("Unsuccessful attempt to activate market for user %s" % request.user) + messages.error(request, 'An error occurred while processing your Alliance Market account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def reset_market_password(request): + logger.debug("reset_market_password called by user %s" % request.user) + if MarketTasks.has_account(request.user): + result = MarketManager.update_user_password(request.user.market.username) + # false we failed + if result != "": + logger.info("Successfully reset market password for user %s" % request.user) + messages.success(request, 'Reset Alliance Market password.') + credentials = { + 'username': request.user.market.username, + 'password': result, + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'Alliance Market'}) + + logger.error("Unsuccessful attempt to reset market password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your Alliance Market account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def set_market_password(request): + logger.debug("set_market_password called by user %s" % request.user) + if request.method == 'POST': + logger.debug("Received POST request with form.") + form = ServicePasswordForm(request.POST) + logger.debug("Form is valid: %s" % form.is_valid()) + if form.is_valid() and MarketTasks.has_account(request.user): + password = form.cleaned_data['password'] + logger.debug("Form contains password of length %s" % len(password)) + result = MarketManager.update_custom_password(request.user.market.username, password) + if result != "": + logger.info("Successfully reset market password for user %s" % request.user) + messages.success(request, 'Set Alliance Market password.') + else: + logger.error("Failed to install custom market password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your Alliance Market account.') + return redirect("auth_services") + else: + logger.debug("Request is not type POST - providing empty form.") + form = ServicePasswordForm() + + logger.debug("Rendering form for user %s" % request.user) + context = {'form': form, 'service': 'Market'} + return render(request, 'registered/service_password.html', context=context) diff --git a/services/modules/mumble/__init__.py b/services/modules/mumble/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/mumble/admin.py b/services/modules/mumble/admin.py new file mode 100644 index 00000000..b475ef4b --- /dev/null +++ b/services/modules/mumble/admin.py @@ -0,0 +1,11 @@ +from __future__ import unicode_literals +from django.contrib import admin +from .models import MumbleUser + + +class MumbleUserAdmin(admin.ModelAdmin): + fields = ('user', 'username', 'groups') # pwhash is hidden from admin panel + list_display = ('user', 'username', 'groups') + search_fields = ('user__username', 'username', 'groups') + +admin.site.register(MumbleUser, MumbleUserAdmin) diff --git a/services/modules/mumble/apps.py b/services/modules/mumble/apps.py new file mode 100644 index 00000000..b104c642 --- /dev/null +++ b/services/modules/mumble/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class MumbleServiceConfig(AppConfig): + name = 'mumble' diff --git a/services/modules/mumble/auth_hooks.py b/services/modules/mumble/auth_hooks.py new file mode 100644 index 00000000..c191b01a --- /dev/null +++ b/services/modules/mumble/auth_hooks.py @@ -0,0 +1,68 @@ +from __future__ import unicode_literals +from django.template.loader import render_to_string +from django.conf import settings +from notifications import notify + +from alliance_auth import hooks +from services.hooks import ServicesHook +from .tasks import MumbleTasks +from .manager import MumbleManager +from .urls import urlpatterns + +import logging + +logger = logging.getLogger(__name__) + + +class MumbleService(ServicesHook): + def __init__(self): + ServicesHook.__init__(self) + self.name = 'mumble' + self.urlpatterns = urlpatterns + self.service_url = settings.MUMBLE_URL + + def delete_user(self, user, notify_user=False): + logging.debug("Deleting user %s %s account" % (user, self.name)) + if MumbleManager.delete_user(user): + if notify_user: + notify(user, 'Mumble Account Disabled', level='danger') + return True + return False + + def update_groups(self, user): + logger.debug("Updating %s groups for %s" % (self.name, user)) + if MumbleTasks.has_account(user): + MumbleTasks.update_groups.delay(user.pk) + + def validate_user(self, user): + if MumbleTasks.has_account(user) and not self.service_active_for_user(user): + self.delete_user(user, notify_user=True) + + def update_all_groups(self): + logger.debug("Updating all %s groups" % self.name) + MumbleTasks.update_all_groups.delay() + + def service_enabled_members(self): + return settings.ENABLE_AUTH_MUMBLE or False + + def service_enabled_blues(self): + return settings.ENABLE_BLUE_MUMBLE or False + + def render_services_ctrl(self, request): + urls = self.Urls() + urls.auth_activate = 'auth_activate_mumble' + urls.auth_deactivate = 'auth_deactivate_mumble' + urls.auth_reset_password = 'auth_reset_mumble_password' + urls.auth_set_password = 'auth_set_mumble_password' + + return render_to_string(self.service_ctrl_template, { + 'service_name': self.title, + 'urls': urls, + 'service_url': self.service_url, + 'username': request.user.mumble.username if MumbleTasks.has_account(request.user) else '', + }, request=request) + + +@hooks.register('services_hook') +def register_mumble_service(): + return MumbleService() diff --git a/services/modules/mumble/manager.py b/services/modules/mumble/manager.py new file mode 100755 index 00000000..71612ee5 --- /dev/null +++ b/services/modules/mumble/manager.py @@ -0,0 +1,105 @@ +from __future__ import unicode_literals +import random +import string +import hashlib + +from django.core.exceptions import ObjectDoesNotExist + +from .models import MumbleUser + +import logging + +logger = logging.getLogger(__name__) + + +class MumbleManager: + def __init__(self): + pass + + @staticmethod + def __santatize_username(username): + sanatized = username.replace(" ", "_") + return sanatized + + @staticmethod + def __generate_random_pass(): + return ''.join([random.choice(string.ascii_letters + string.digits) for n in range(16)]) + + @staticmethod + def __generate_username(username, corp_ticker): + return "[" + corp_ticker + "]" + username + + @staticmethod + def __generate_username_blue(username, corp_ticker): + return "[BLUE][" + corp_ticker + "]" + username + + @staticmethod + def _gen_pwhash(password): + return hashlib.sha1(password.encode('utf-8')).hexdigest() + + @staticmethod + def create_user(user, corp_ticker, username, blue=False): + logger.debug("Creating%s mumble user with username %s and ticker %s" % (' blue' if blue else '', + username, corp_ticker)) + username_clean = MumbleManager.__santatize_username( + MumbleManager.__generate_username_blue(username, corp_ticker) if blue else + MumbleManager.__generate_username(username, corp_ticker)) + password = MumbleManager.__generate_random_pass() + pwhash = MumbleManager._gen_pwhash(password) + logger.debug("Proceeding with mumble user creation: clean username %s, pwhash starts with %s" % ( + username_clean, pwhash[0:5])) + if not MumbleUser.objects.filter(username=username_clean).exists(): + logger.info("Creating mumble user %s" % username_clean) + MumbleUser.objects.create(user=user, username=username_clean, pwhash=pwhash) + return username_clean, password + else: + logger.warn("Mumble user %s already exists.") + return False + + @staticmethod + def delete_user(user): + logger.debug("Deleting user %s from mumble." % user) + if MumbleUser.objects.filter(user=user).exists(): + MumbleUser.objects.filter(user=user).delete() + logger.info("Deleted user %s from mumble" % user) + return True + logger.error("Unable to delete user %s from mumble: MumbleUser model not found" % user) + return False + + @staticmethod + def update_user_password(user, password=None): + logger.debug("Updating mumble user %s password." % user) + if not password: + password = MumbleManager.__generate_random_pass() + pwhash = MumbleManager._gen_pwhash(password) + logger.debug("Proceeding with mumble user %s password update - pwhash starts with %s" % (user, pwhash[0:5])) + try: + model = MumbleUser.objects.get(user=user) + model.pwhash = pwhash + model.save() + return password + except ObjectDoesNotExist: + logger.error("User %s not found on mumble. Unable to update password." % user) + return False + + @staticmethod + def update_groups(user, groups): + logger.debug("Updating mumble user %s groups %s" % (user, groups)) + safe_groups = list(set([g.replace(' ', '-') for g in groups])) + groups = '' + for g in safe_groups: + groups = groups + g + ',' + groups = groups.strip(',') + if MumbleUser.objects.filter(user=user).exists(): + logger.info("Updating mumble user %s groups to %s" % (user, safe_groups)) + model = MumbleUser.objects.get(user=user) + model.groups = groups + model.save() + return True + else: + logger.error("User %s not found on mumble. Unable to update groups." % user) + return False + + @staticmethod + def user_exists(username): + return MumbleUser.objects.filter(username=username).exists() diff --git a/services/modules/mumble/migrations/0001_initial.py b/services/modules/mumble/migrations/0001_initial.py new file mode 100644 index 00000000..a2cdb69c --- /dev/null +++ b/services/modules/mumble/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 00:58 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='MumbleUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=254, unique=True)), + ('pwhash', models.CharField(max_length=40)), + ('groups', models.TextField(blank=True, null=True)), + ], + options={ + 'db_table': 'services_mumbleuser', + }, + ), + ] diff --git a/services/modules/mumble/migrations/0002_auto_20161212_0100.py b/services/modules/mumble/migrations/0002_auto_20161212_0100.py new file mode 100644 index 00000000..784d4276 --- /dev/null +++ b/services/modules/mumble/migrations/0002_auto_20161212_0100.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 01:00 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('mumble', '0001_initial'), + ] + + operations = [ + migrations.AlterModelTable( + name='mumbleuser', + table=None, + ), + ] diff --git a/services/modules/mumble/migrations/0003_mumbleuser_user.py b/services/modules/mumble/migrations/0003_mumbleuser_user.py new file mode 100644 index 00000000..7d8b63c1 --- /dev/null +++ b/services/modules/mumble/migrations/0003_mumbleuser_user.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 03:31 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('mumble', '0002_auto_20161212_0100'), + ] + + operations = [ + migrations.AddField( + model_name='mumbleuser', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='mumble', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/services/modules/mumble/migrations/0004_auto_20161214_1024.py b/services/modules/mumble/migrations/0004_auto_20161214_1024.py new file mode 100644 index 00000000..35daf08d --- /dev/null +++ b/services/modules/mumble/migrations/0004_auto_20161214_1024.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2016-12-14 10:24 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('mumble', '0003_mumbleuser_user'), + ] + + operations = [ + migrations.AlterField( + model_name='mumbleuser', + name='user', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='mumble', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/services/modules/mumble/migrations/__init__.py b/services/modules/mumble/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/mumble/models.py b/services/modules/mumble/models.py new file mode 100644 index 00000000..28e303b9 --- /dev/null +++ b/services/modules/mumble/models.py @@ -0,0 +1,14 @@ +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible +from django.db import models + + +@python_2_unicode_compatible +class MumbleUser(models.Model): + user = models.OneToOneField('auth.User', related_name='mumble', null=True) + username = models.CharField(max_length=254, unique=True) + pwhash = models.CharField(max_length=40) + groups = models.TextField(blank=True, null=True) + + def __str__(self): + return self.username diff --git a/services/modules/mumble/tasks.py b/services/modules/mumble/tasks.py new file mode 100644 index 00000000..d9986c0e --- /dev/null +++ b/services/modules/mumble/tasks.py @@ -0,0 +1,63 @@ +from __future__ import unicode_literals + +from alliance_auth.celeryapp import app +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist + +from .models import MumbleUser +from .manager import MumbleManager + +import logging + +logger = logging.getLogger(__name__) + + +class MumbleTasks: + def __init__(self): + pass + + @staticmethod + def has_account(user): + try: + return user.mumble.username != '' + except ObjectDoesNotExist: + return False + + @staticmethod + def disable_mumble(): + if settings.ENABLE_AUTH_MUMBLE: + logger.warn("ENABLE_AUTH_MUMBLE still True, after disabling users will still be able to create mumble accounts") + if settings.ENABLE_BLUE_MUMBLE: + logger.warn("ENABLE_BLUE_MUMBLE still True, after disabling blues will still be able to create mumble accounts") + logger.info("Deleting all MumbleUser models") + MumbleUser.objects.all().delete() + + @staticmethod + @app.task(bind=True) + def update_groups(self, pk): + user = User.objects.get(pk=pk) + logger.debug("Updating mumble groups for user %s" % user) + if MumbleTasks.has_account(user): + groups = [] + for group in user.groups.all(): + groups.append(str(group.name)) + if len(groups) == 0: + groups.append('empty') + logger.debug("Updating user %s mumble groups to %s" % (user, groups)) + try: + if not MumbleManager.update_groups(user, groups): + raise Exception("Group sync failed") + except: + logger.exception("Mumble group sync failed for %s, retrying in 10 mins" % user) + raise self.retry(countdown=60 * 10) + logger.debug("Updated user %s mumble groups." % user) + else: + logger.debug("User %s does not have a mumble account, skipping" % user) + + @staticmethod + @app.task + def update_all_groups(): + logger.debug("Updating ALL mumble groups") + for mumble_user in MumbleUser.objects.exclude(username__exact=''): + MumbleTasks.update_groups.delay(mumble_user.user.pk) diff --git a/services/modules/mumble/tests.py b/services/modules/mumble/tests.py new file mode 100644 index 00000000..6b6f0339 --- /dev/null +++ b/services/modules/mumble/tests.py @@ -0,0 +1,201 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase, RequestFactory +from django.conf import settings +from django import urls +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist + +from alliance_auth.tests.auth_utils import AuthUtils + +from .auth_hooks import MumbleService +from .models import MumbleUser +from .tasks import MumbleTasks + +import hashlib + +MODULE_PATH = 'services.modules.mumble' + + +def gen_pwhash(password): + return hashlib.sha1(password).hexdigest() + + +class MumbleHooksTestCase(TestCase): + def setUp(self): + self.member = 'member_user' + member = AuthUtils.create_member(self.member) + MumbleUser.objects.create(user=member, username=self.member, pwhash='password', groups='Member') + self.blue = 'blue_user' + blue = AuthUtils.create_blue(self.blue) + MumbleUser.objects.create(user=blue, username=self.blue, pwhash='password', groups='Blue') + self.none_user = 'none_user' + none_user = AuthUtils.create_user(self.none_user) + self.service = MumbleService + + def test_has_account(self): + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(MumbleTasks.has_account(member)) + self.assertTrue(MumbleTasks.has_account(blue)) + self.assertFalse(MumbleTasks.has_account(none_user)) + + def test_service_enabled(self): + service = self.service() + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(service.service_enabled_members()) + self.assertTrue(service.service_enabled_blues()) + + self.assertEqual(service.service_active_for_user(member), settings.ENABLE_AUTH_MUMBLE) + self.assertEqual(service.service_active_for_user(blue), settings.ENABLE_BLUE_MUMBLE) + self.assertFalse(service.service_active_for_user(none_user)) + + @mock.patch(MODULE_PATH + '.tasks.MumbleManager') + def test_update_all_groups(self, manager): + service = self.service() + service.update_all_groups() + # Check member and blue user have groups updated + self.assertTrue(manager.update_groups.called) + self.assertEqual(manager.update_groups.call_count, 2) + + def test_update_groups(self): + # Check member has Member group updated + service = self.service() + member = User.objects.get(username=self.member) + member.mumble.groups = '' # Remove the group set in setUp + member.mumble.save() + + service.update_groups(member) + + mumble_user = MumbleUser.objects.get(user=member) + self.assertIn(settings.DEFAULT_AUTH_GROUP, mumble_user.groups) + + # Check none user does not have groups updated + with mock.patch(MODULE_PATH + '.tasks.MumbleManager') as manager: + service = self.service() + none_user = User.objects.get(username=self.none_user) + service.update_groups(none_user) + self.assertFalse(manager.update_groups.called) + + def test_validate_user(self): + service = self.service() + # Test member is not deleted + member = User.objects.get(username=self.member) + service.validate_user(member) + self.assertTrue(member.mumble) + + # Test none user is deleted + none_user = User.objects.get(username=self.none_user) + MumbleUser.objects.create(user=none_user, username='mr no-name', pwhash='password', groups='Blue,Orange') + service.validate_user(none_user) + with self.assertRaises(ObjectDoesNotExist): + none_mumble = User.objects.get(username=self.none_user).mumble + + def test_delete_user(self): + member = User.objects.get(username=self.member) + + service = self.service() + result = service.delete_user(member) + + self.assertTrue(result) + with self.assertRaises(ObjectDoesNotExist): + mumble_user = User.objects.get(username=self.member).mumble + + def test_render_services_ctrl(self): + service = self.service() + member = User.objects.get(username=self.member) + request = RequestFactory().get('/en/services/') + request.user = member + + response = service.render_services_ctrl(request) + self.assertTemplateUsed(service.service_ctrl_template) + self.assertIn(urls.reverse('auth_deactivate_mumble'), response) + self.assertIn(urls.reverse('auth_reset_mumble_password'), response) + self.assertIn(urls.reverse('auth_set_mumble_password'), response) + + # Test register becomes available + member.mumble.delete() + member = User.objects.get(username=self.member) + request.user = member + response = service.render_services_ctrl(request) + self.assertIn(urls.reverse('auth_activate_mumble'), response) + + +class MumbleViewsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.member.set_password('password') + self.member.email = 'auth_member@example.com' + self.member.save() + AuthUtils.add_main_character(self.member, 'auth_member', '12345', corp_id='111', corp_name='Test Corporation', + corp_ticker='TESTR') + + def login(self): + self.client.login(username=self.member.username, password='password') + + def test_activate(self): + self.login() + expected_username = '[TESTR]auth_member' + response = self.client.get(urls.reverse('auth_activate_mumble'), follow=False) + self.assertEqual(response.status_code, 200) + self.assertContains(response, expected_username) + mumble_user = MumbleUser.objects.get(user=self.member) + self.assertEqual(mumble_user.username, expected_username) + self.assertTrue(mumble_user.pwhash) + self.assertEqual(self.member.mumble.username, expected_username) + + def test_deactivate(self): + self.login() + MumbleUser.objects.create(user=self.member, username='some member') + + response = self.client.get(urls.reverse('auth_deactivate_mumble')) + + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + with self.assertRaises(ObjectDoesNotExist): + mumble_user = User.objects.get(pk=self.member.pk).mumble + + def test_set_password(self): + self.login() + MumbleUser.objects.create(user=self.member, username='some member', pwhash='old') + + response = self.client.post(urls.reverse('auth_set_mumble_password'), data={'password': '1234asdf'}) + + self.assertNotEqual(MumbleUser.objects.get(user=self.member).pwhash, 'old') + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + + def test_reset_password(self): + self.login() + MumbleUser.objects.create(user=self.member, username='some member', pwhash='old') + + response = self.client.get(urls.reverse('auth_reset_mumble_password')) + + self.assertNotEqual(MumbleUser.objects.get(user=self.member).pwhash, 'old') + self.assertTemplateUsed(response, 'registered/service_credentials.html') + self.assertContains(response, 'some member') + + +class MumbleManagerTestCase(TestCase): + def setUp(self): + from .manager import MumbleManager + self.manager = MumbleManager + + def test_generate_random_password(self): + password = self.manager._MumbleManager__generate_random_pass() + + self.assertEqual(len(password), 16) + self.assertIsInstance(password, type('')) + + def test_gen_pwhash(self): + pwhash = self.manager._gen_pwhash('test') + + self.assertEqual(pwhash, 'a94a8fe5ccb19ba61c4c0873d391e987982fbbd3') diff --git a/services/modules/mumble/urls.py b/services/modules/mumble/urls.py new file mode 100644 index 00000000..ec22746f --- /dev/null +++ b/services/modules/mumble/urls.py @@ -0,0 +1,17 @@ +from __future__ import unicode_literals +from django.conf.urls import url, include + +from . import views + +module_urls = [ + # Mumble service control + url(r'^activate/$', views.activate_mumble, name='auth_activate_mumble'), + url(r'^deactivate/$', views.deactivate_mumble, name='auth_deactivate_mumble'), + url(r'^reset_password/$', views.reset_mumble_password, + name='auth_reset_mumble_password'), + url(r'^set_password/$', views.set_mumble_password, name='auth_set_mumble_password'), +] + +urlpatterns = [ + url(r'^mumble/', include(module_urls)) +] diff --git a/services/modules/mumble/views.py b/services/modules/mumble/views.py new file mode 100644 index 00000000..497f0796 --- /dev/null +++ b/services/modules/mumble/views.py @@ -0,0 +1,120 @@ +from __future__ import unicode_literals +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect +from django.contrib import messages + +from authentication.decorators import members_and_blues +from eveonline.managers import EveManager +from eveonline.models import EveAllianceInfo +from authentication.states import MEMBER_STATE, BLUE_STATE, NONE_STATE +from authentication.models import AuthServicesInfo + +from services.forms import ServicePasswordForm + +from .manager import MumbleManager +from .tasks import MumbleTasks +from .models import MumbleUser + +import logging + +logger = logging.getLogger(__name__) + + +@login_required +@members_and_blues() +def activate_mumble(request): + logger.debug("activate_mumble called by user %s" % request.user) + authinfo = AuthServicesInfo.objects.get(user=request.user) + character = EveManager.get_main_character(request.user) + ticker = character.corporation_ticker + + if authinfo.state == BLUE_STATE: + logger.debug("Adding mumble user for blue user %s with main character %s" % (request.user, character)) + # Blue members should have alliance ticker (if in alliance) + if EveAllianceInfo.objects.filter(alliance_id=character.alliance_id).exists(): + alliance = EveAllianceInfo.objects.filter(alliance_id=character.alliance_id)[0] + ticker = alliance.alliance_ticker + result = MumbleManager.create_user(request.user, ticker, character.character_name, blue=True) + else: + logger.debug("Adding mumble user for user %s with main character %s" % (request.user, character)) + result = MumbleManager.create_user(request.user, ticker, character.character_name) + + if result: + logger.debug("Updated authserviceinfo for user %s with mumble credentials. Updating groups." % request.user) + MumbleTasks.update_groups.apply(request.user.pk) # Run synchronously to prevent timing issues + logger.info("Successfully activated mumble for user %s" % request.user) + messages.success(request, 'Activated Mumble account.') + credentials = { + 'username': result[0], + 'password': result[1], + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'Mumble'}) + else: + logger.error("Unsuccessful attempt to activate mumble for user %s" % request.user) + messages.error(request, 'An error occurred while processing your Mumble account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def deactivate_mumble(request): + logger.debug("deactivate_mumble called by user %s" % request.user) + # if we successfully remove the user or the user is already removed + if MumbleManager.delete_user(request.user): + logger.info("Successfully deactivated mumble for user %s" % request.user) + messages.success(request, 'Deactivated Mumble account.') + else: + logger.error("Unsuccessful attempt to deactivate mumble for user %s" % request.user) + messages.error(request, 'An error occurred while processing your Mumble account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def reset_mumble_password(request): + logger.debug("reset_mumble_password called by user %s" % request.user) + result = MumbleManager.update_user_password(request.user) + + # if blank we failed + if result != "": + logger.info("Successfully reset mumble password for user %s" % request.user) + messages.success(request, 'Reset Mumble password.') + credentials = { + 'username': request.user.mumble.username, + 'password': result, + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'Mumble'}) + else: + logger.error("Unsuccessful attempt to reset mumble password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your Mumble account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def set_mumble_password(request): + logger.debug("set_mumble_password called by user %s" % request.user) + if request.method == 'POST': + logger.debug("Received POST request with form.") + form = ServicePasswordForm(request.POST) + logger.debug("Form is valid: %s" % form.is_valid()) + if form.is_valid() and MumbleTasks.has_account(request.user): + password = form.cleaned_data['password'] + logger.debug("Form contains password of length %s" % len(password)) + result = MumbleManager.update_user_password(request.user, password=password) + if result != "": + logger.info("Successfully reset forum password for user %s" % request.user) + messages.success(request, 'Set Mumble password.') + else: + logger.error("Failed to install custom mumble password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your Mumble account.') + return redirect("auth_services") + else: + logger.debug("Request is not type POST - providing empty form.") + form = ServicePasswordForm() + + logger.debug("Rendering form for user %s" % request.user) + context = {'form': form, 'service': 'Mumble'} + return render(request, 'registered/service_password.html', context=context) diff --git a/services/modules/openfire/__init__.py b/services/modules/openfire/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/openfire/admin.py b/services/modules/openfire/admin.py new file mode 100644 index 00000000..44d88289 --- /dev/null +++ b/services/modules/openfire/admin.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from django.contrib import admin +from .models import OpenfireUser + + +class OpenfireUserAdmin(admin.ModelAdmin): + list_display = ('user', 'username') + search_fields = ('user__username', 'username') + +admin.site.register(OpenfireUser, OpenfireUserAdmin) diff --git a/services/modules/openfire/apps.py b/services/modules/openfire/apps.py new file mode 100644 index 00000000..f47161c0 --- /dev/null +++ b/services/modules/openfire/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class OpenfireServiceConfig(AppConfig): + name = 'openfire' diff --git a/services/modules/openfire/auth_hooks.py b/services/modules/openfire/auth_hooks.py new file mode 100644 index 00000000..ab0b8b2a --- /dev/null +++ b/services/modules/openfire/auth_hooks.py @@ -0,0 +1,93 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.template.loader import render_to_string + +from services.hooks import ServicesHook, MenuItemHook +from alliance_auth import hooks + +from .urls import urlpatterns +from .tasks import OpenfireTasks + +import logging + +logger = logging.getLogger(__name__) + + +class OpenfireService(ServicesHook): + def __init__(self): + ServicesHook.__init__(self) + self.name = 'openfire' + self.urlpatterns = urlpatterns + self.service_url = settings.JABBER_URL + + @property + def title(self): + return "Jabber" + + def delete_user(self, user, notify_user=False): + logger.debug('Deleting user %s %s account' % (user, self.name)) + return OpenfireTasks.delete_user(user, notify_user=notify_user) + + def validate_user(self, user): + logger.debug('Validating user %s %s account' % (user, self.name)) + if OpenfireTasks.has_account(user) and not self.service_active_for_user(user): + self.delete_user(user, notify_user=True) + + def update_groups(self, user): + logger.debug('Updating %s groups for %s' % (self.name, user)) + if OpenfireTasks.has_account(user): + OpenfireTasks.update_groups.delay(user.pk) + + def update_all_groups(self): + logger.debug('Update all %s groups called' % self.name) + OpenfireTasks.update_all_groups.delay() + + def service_enabled_members(self): + return settings.ENABLE_AUTH_JABBER or False # TODO: Rename this setting + + def service_enabled_blues(self): + return settings.ENABLE_BLUE_JABBER or False # TODO: Rename this setting + + def render_services_ctrl(self, request): + """ + Example for rendering the service control panel row + You can override the default template and create a + custom one if you wish. + :param request: + :return: + """ + urls = self.Urls() + urls.auth_activate = 'auth_activate_openfire' + urls.auth_deactivate = 'auth_deactivate_openfire' + urls.auth_set_password = 'auth_set_openfire_password' + urls.auth_reset_password = 'auth_reset_openfire_password' + return render_to_string(self.service_ctrl_template, { + 'service_name': self.title, + 'urls': urls, + 'service_url': self.service_url, + 'username': request.user.openfire.username if OpenfireTasks.has_account(request.user) else '' + }, request=request) + + +@hooks.register('services_hook') +def register_service(): + return OpenfireService() + + +class JabberBroadcast(MenuItemHook): + def __init__(self): + MenuItemHook.__init__(self, + 'Jabber Broadcast', + 'fa fa-lock fa-fw fa-bullhorn grayiconecolor', + 'auth_jabber_broadcast_view') + + def render(self, request): + if request.user.has_perm('auth.jabber_broadcast'): + return MenuItemHook.render(self, request) + return '' + + +@hooks.register('menu_util_hook') +def register_menu(): + return JabberBroadcast() diff --git a/services/modules/openfire/forms.py b/services/modules/openfire/forms.py new file mode 100644 index 00000000..593c710c --- /dev/null +++ b/services/modules/openfire/forms.py @@ -0,0 +1,9 @@ +from __future__ import unicode_literals + +from django import forms +from django.utils.translation import ugettext_lazy as _ + + +class JabberBroadcastForm(forms.Form): + group = forms.ChoiceField(label=_('Group'), widget=forms.Select) + message = forms.CharField(label=_('Message'), widget=forms.Textarea) diff --git a/services/managers/openfire_manager.py b/services/modules/openfire/manager.py similarity index 98% rename from services/managers/openfire_manager.py rename to services/modules/openfire/manager.py index 4cdb054a..257907b7 100755 --- a/services/managers/openfire_manager.py +++ b/services/modules/openfire/manager.py @@ -1,7 +1,8 @@ from __future__ import unicode_literals from django.utils import six import re -import os +import random +import string try: from urlparse import urlparse except ImportError: @@ -42,7 +43,7 @@ class OpenfireManager: @staticmethod def __generate_random_pass(): - return os.urandom(8).encode('hex') + return ''.join([random.choice(string.ascii_letters + string.digits) for n in range(16)]) @staticmethod def _sanitize_groupname(name): diff --git a/services/modules/openfire/migrations/0001_initial.py b/services/modules/openfire/migrations/0001_initial.py new file mode 100644 index 00000000..d6f89303 --- /dev/null +++ b/services/modules/openfire/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 03:27 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='OpenfireUser', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='openfire', serialize=False, to=settings.AUTH_USER_MODEL)), + ('username', models.CharField(max_length=254)), + ], + ), + ] diff --git a/services/modules/openfire/migrations/__init__.py b/services/modules/openfire/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/openfire/models.py b/services/modules/openfire/models.py new file mode 100644 index 00000000..dca95993 --- /dev/null +++ b/services/modules/openfire/models.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible +from django.db import models + + +@python_2_unicode_compatible +class OpenfireUser(models.Model): + user = models.OneToOneField('auth.User', + primary_key=True, + on_delete=models.CASCADE, + related_name='openfire') + username = models.CharField(max_length=254) + + def __str__(self): + return self.username diff --git a/services/modules/openfire/tasks.py b/services/modules/openfire/tasks.py new file mode 100644 index 00000000..c463a9f1 --- /dev/null +++ b/services/modules/openfire/tasks.py @@ -0,0 +1,75 @@ +from __future__ import unicode_literals + +import logging + +from alliance_auth.celeryapp import app +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth.models import User +from notifications import notify + +from services.modules.openfire.manager import OpenfireManager + +from .models import OpenfireUser + +logger = logging.getLogger(__name__) + + +class OpenfireTasks: + def __init__(self): + pass + + @classmethod + def delete_user(cls, user, notify_user=False): + if cls.has_account(user): + logger.debug("User %s has jabber account %s. Deleting." % (user, user.openfire.username)) + OpenfireManager.delete_user(user.openfire.username) + user.openfire.delete() + if notify_user: + notify(user, 'Jabber Account Disabled', level='danger') + return True + return False + + @staticmethod + def has_account(user): + try: + return user.openfire.username != '' + except ObjectDoesNotExist: + return False + + @staticmethod + def disable_jabber(): + if settings.ENABLE_AUTH_JABBER: + logger.warn("ENABLE_AUTH_JABBER still True, after disabling users will still be able to create jabber accounts") + if settings.ENABLE_BLUE_JABBER: + logger.warn("ENABLE_BLUE_JABBER still True, after disabling blues will still be able to create jabber accounts") + logging.debug("Deleting all Openfire users") + OpenfireUser.objects.all().delete() + + @staticmethod + @app.task(bind=True) + def update_groups(self, pk): + user = User.objects.get(pk=pk) + logger.debug("Updating jabber groups for user %s" % user) + if OpenfireTasks.has_account(user): + groups = [] + for group in user.groups.all(): + groups.append(str(group.name)) + if len(groups) == 0: + groups.append('empty') + logger.debug("Updating user %s jabber groups to %s" % (user, groups)) + try: + OpenfireManager.update_user_groups(user.openfire.username, groups) + except: + logger.exception("Jabber group sync failed for %s, retrying in 10 mins" % user) + raise self.retry(countdown=60 * 10) + logger.debug("Updated user %s jabber groups." % user) + else: + logger.debug("User does not have an openfire account") + + @staticmethod + @app.task + def update_all_groups(): + logger.debug("Updating ALL jabber groups") + for openfire_user in OpenfireUser.objects.exclude(username__exact=''): + OpenfireTasks.update_groups.delay(openfire_user.user.pk) diff --git a/services/modules/openfire/tests.py b/services/modules/openfire/tests.py new file mode 100644 index 00000000..5257f7cc --- /dev/null +++ b/services/modules/openfire/tests.py @@ -0,0 +1,207 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase, RequestFactory +from django.conf import settings +from django import urls +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist + +from alliance_auth.tests.auth_utils import AuthUtils + +from .auth_hooks import OpenfireService +from .models import OpenfireUser +from .tasks import OpenfireTasks + +MODULE_PATH = 'services.modules.openfire' + + +class OpenfireHooksTestCase(TestCase): + def setUp(self): + self.member = 'member_user' + member = AuthUtils.create_member(self.member) + OpenfireUser.objects.create(user=member, username=self.member) + self.blue = 'blue_user' + blue = AuthUtils.create_blue(self.blue) + OpenfireUser.objects.create(user=blue, username=self.blue) + self.none_user = 'none_user' + none_user = AuthUtils.create_user(self.none_user) + self.service = OpenfireService + + def test_has_account(self): + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(OpenfireTasks.has_account(member)) + self.assertTrue(OpenfireTasks.has_account(blue)) + self.assertFalse(OpenfireTasks.has_account(none_user)) + + def test_service_enabled(self): + service = self.service() + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(service.service_enabled_members()) + self.assertTrue(service.service_enabled_blues()) + + self.assertEqual(service.service_active_for_user(member), settings.ENABLE_AUTH_JABBER) + self.assertEqual(service.service_active_for_user(blue), settings.ENABLE_BLUE_JABBER) + self.assertFalse(service.service_active_for_user(none_user)) + + @mock.patch(MODULE_PATH + '.tasks.OpenfireManager') + def test_update_all_groups(self, manager): + service = self.service() + service.update_all_groups() + # Check member and blue user have groups updated + self.assertTrue(manager.update_user_groups.called) + self.assertEqual(manager.update_user_groups.call_count, 2) + + def test_update_groups(self): + # Check member has Member group updated + with mock.patch(MODULE_PATH + '.tasks.OpenfireManager') as manager: + service = self.service() + member = User.objects.get(username=self.member) + service.update_groups(member) + self.assertTrue(manager.update_user_groups.called) + args, kwargs = manager.update_user_groups.call_args + user_id, groups = args + self.assertIn(settings.DEFAULT_AUTH_GROUP, groups) + self.assertEqual(user_id, member.openfire.username) + + # Check none user does not have groups updated + with mock.patch(MODULE_PATH + '.tasks.OpenfireManager') as manager: + service = self.service() + none_user = User.objects.get(username=self.none_user) + service.update_groups(none_user) + self.assertFalse(manager.update_user_groups.called) + + @mock.patch(MODULE_PATH + '.tasks.OpenfireManager') + def test_validate_user(self, manager): + service = self.service() + # Test member is not deleted + member = User.objects.get(username=self.member) + service.validate_user(member) + self.assertTrue(member.openfire) + + # Test none user is deleted + none_user = User.objects.get(username=self.none_user) + OpenfireUser.objects.create(user=none_user, username='abc123') + service.validate_user(none_user) + self.assertTrue(manager.delete_user.called) + with self.assertRaises(ObjectDoesNotExist): + none_openfire = User.objects.get(username=self.none_user).openfire + + @mock.patch(MODULE_PATH + '.tasks.OpenfireManager') + def test_delete_user(self, manager): + member = User.objects.get(username=self.member) + + service = self.service() + result = service.delete_user(member) + + self.assertTrue(result) + self.assertTrue(manager.delete_user.called) + with self.assertRaises(ObjectDoesNotExist): + openfire_user = User.objects.get(username=self.member).openfire + + def test_render_services_ctrl(self): + service = self.service() + member = User.objects.get(username=self.member) + request = RequestFactory().get('/en/services/') + request.user = member + + response = service.render_services_ctrl(request) + self.assertTemplateUsed(service.service_ctrl_template) + self.assertIn(urls.reverse('auth_deactivate_openfire'), response) + self.assertIn(urls.reverse('auth_reset_openfire_password'), response) + self.assertIn(urls.reverse('auth_set_openfire_password'), response) + + # Test register becomes available + member.openfire.delete() + member = User.objects.get(username=self.member) + request.user = member + response = service.render_services_ctrl(request) + self.assertIn(urls.reverse('auth_activate_openfire'), response) + + +class OpenfireViewsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.member.set_password('password') + self.member.email = 'auth_member@example.com' + self.member.save() + AuthUtils.add_main_character(self.member, 'auth_member', '12345', corp_id='111', corp_name='Test Corporation') + + def login(self): + self.client.login(username=self.member.username, password='password') + + @mock.patch(MODULE_PATH + '.tasks.OpenfireManager') + @mock.patch(MODULE_PATH + '.views.OpenfireManager') + def test_activate(self, manager, tasks_manager): + self.login() + expected_username = 'auth_member' + manager.add_user.return_value = (expected_username, 'abc123') + + response = self.client.get(urls.reverse('auth_activate_openfire')) + + self.assertTrue(manager.add_user.called) + self.assertTrue(tasks_manager.update_user_groups.called) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed('registered/service_credentials.html') + self.assertContains(response, expected_username) + openfire_user = OpenfireUser.objects.get(user=self.member) + self.assertEqual(openfire_user.username, expected_username) + + @mock.patch(MODULE_PATH + '.tasks.OpenfireManager') + def test_deactivate(self, manager): + self.login() + OpenfireUser.objects.create(user=self.member, username='some member') + + response = self.client.get(urls.reverse('auth_deactivate_openfire')) + + self.assertTrue(manager.delete_user.called) + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + with self.assertRaises(ObjectDoesNotExist): + openfire_user = User.objects.get(pk=self.member.pk).openfire + + @mock.patch(MODULE_PATH + '.views.OpenfireManager') + def test_set_password(self, manager): + self.login() + OpenfireUser.objects.create(user=self.member, username='some member') + + response = self.client.post(urls.reverse('auth_set_openfire_password'), data={'password': '1234asdf'}) + + self.assertTrue(manager.update_user_pass.called) + args, kwargs = manager.update_user_pass.call_args + self.assertEqual(kwargs['password'], '1234asdf') + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + + @mock.patch(MODULE_PATH + '.views.OpenfireManager') + def test_reset_password(self, manager): + self.login() + OpenfireUser.objects.create(user=self.member, username='some member') + + manager.update_user_pass.return_value = 'hunter2' + + response = self.client.get(urls.reverse('auth_reset_openfire_password')) + + self.assertTemplateUsed(response, 'registered/service_credentials.html') + self.assertContains(response, 'some member') + self.assertContains(response, 'hunter2') + + +class OpenfireManagerTestCase(TestCase): + def setUp(self): + from .manager import OpenfireManager + self.manager = OpenfireManager + + def test_generate_random_password(self): + password = self.manager._OpenfireManager__generate_random_pass() + + self.assertEqual(len(password), 16) + self.assertIsInstance(password, type('')) diff --git a/services/modules/openfire/urls.py b/services/modules/openfire/urls.py new file mode 100644 index 00000000..bb63cbd6 --- /dev/null +++ b/services/modules/openfire/urls.py @@ -0,0 +1,28 @@ +from __future__ import unicode_literals +from django.conf.urls import url, include +from django.conf.urls.i18n import i18n_patterns +from django.utils.translation import ugettext_lazy as _ + +from . import views + +module_urls = [ + # Jabber Service Control + url(r'^activate/$', views.activate_jabber, name='auth_activate_openfire'), + url(r'^deactivate/$', views.deactivate_jabber, name='auth_deactivate_openfire'), + url(r'^reset_password/$', views.reset_jabber_password, name='auth_reset_openfire_password'), +] + +module_i18n_urls = [ + url(_(r'^set_password/$'), views.set_jabber_password, name='auth_set_openfire_password'), +] + +urlpatterns = [ + url(r'^openfire/', include(module_urls)) +] + +urlpatterns += i18n_patterns( + # Jabber Broadcast + url(_(r'^services/jabber_broadcast/$'), views.jabber_broadcast_view, name='auth_jabber_broadcast_view'), + # Jabber + url(r'openfire/', include(module_i18n_urls)) +) diff --git a/services/modules/openfire/views.py b/services/modules/openfire/views.py new file mode 100644 index 00000000..90ff272f --- /dev/null +++ b/services/modules/openfire/views.py @@ -0,0 +1,160 @@ +from __future__ import unicode_literals + +from django.contrib import messages +from django.contrib.auth.decorators import login_required, permission_required +from django.contrib.auth.models import Group +from django.shortcuts import render, redirect + +from authentication.decorators import members_and_blues +from eveonline.managers import EveManager +from eveonline.models import EveCharacter +from services.forms import ServicePasswordForm + +from .manager import OpenfireManager +from .tasks import OpenfireTasks +from .forms import JabberBroadcastForm +from .models import OpenfireUser + +import datetime + +import logging + +logger = logging.getLogger(__name__) + + +@login_required +@members_and_blues() +def activate_jabber(request): + logger.debug("activate_jabber called by user %s" % request.user) + character = EveManager.get_main_character(request.user) + logger.debug("Adding jabber user for user %s with main character %s" % (request.user, character)) + info = OpenfireManager.add_user(character.character_name) + # If our username is blank means we already had a user + if info[0] is not "": + OpenfireUser.objects.update_or_create(user=request.user, defaults={'username': info[0]}) + logger.debug("Updated authserviceinfo for user %s with jabber credentials. Updating groups." % request.user) + OpenfireTasks.update_groups.delay(request.user.pk) + logger.info("Successfully activated jabber for user %s" % request.user) + messages.success(request, 'Activated jabber account.') + credentials = { + 'username': info[0], + 'password': info[1], + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'Jabber'}) + else: + logger.error("Unsuccessful attempt to activate jabber for user %s" % request.user) + messages.error(request, 'An error occurred while processing your jabber account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def deactivate_jabber(request): + logger.debug("deactivate_jabber called by user %s" % request.user) + if OpenfireTasks.has_account(request.user) and OpenfireTasks.delete_user(request.user): + logger.info("Successfully deactivated jabber for user %s" % request.user) + messages.success(request, 'Deactivated jabber account.') + else: + logger.error("Unsuccessful attempt to deactivate jabber for user %s" % request.user) + messages.error(request, 'An error occurred while processing your jabber account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def reset_jabber_password(request): + logger.debug("reset_jabber_password called by user %s" % request.user) + if OpenfireTasks.has_account(request.user): + result = OpenfireManager.update_user_pass(request.user.openfire.username) + # If our username is blank means we failed + if result != "": + logger.info("Successfully reset jabber password for user %s" % request.user) + messages.success(request, 'Reset jabber password.') + credentials = { + 'username': request.user.openfire.username, + 'password': result, + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'Jabber'}) + logger.error("Unsuccessful attempt to reset jabber for user %s" % request.user) + messages.error(request, 'An error occurred while processing your jabber account.') + return redirect("auth_services") + + +@login_required +@permission_required('auth.jabber_broadcast') +def jabber_broadcast_view(request): + logger.debug("jabber_broadcast_view called by user %s" % request.user) + allchoices = [] + if request.user.has_perm('auth.jabber_broadcast_all'): + allchoices.append(('all', 'all')) + for g in Group.objects.all(): + allchoices.append((str(g.name), str(g.name))) + else: + for g in request.user.groups.all(): + allchoices.append((str(g.name), str(g.name))) + if request.method == 'POST': + form = JabberBroadcastForm(request.POST) + form.fields['group'].choices = allchoices + logger.debug("Received POST request containing form, valid: %s" % form.is_valid()) + if form.is_valid(): + main_char = EveManager.get_main_character(request.user) + logger.debug("Processing jabber broadcast for user %s with main character %s" % (request.user, main_char)) + if main_char is not None: + message_to_send = form.cleaned_data[ + 'message'] + "\n##### SENT BY: " + "[" + main_char.corporation_ticker + "]" + \ + main_char.character_name + " TO: " + \ + form.cleaned_data['group'] + " WHEN: " + datetime.datetime.utcnow().strftime( + "%Y-%m-%d %H:%M:%S") + " #####\n##### Replies are NOT monitored #####\n" + group_to_send = form.cleaned_data['group'] + + OpenfireManager.send_broadcast_threaded(group_to_send, message_to_send, ) + + else: + message_to_send = form.cleaned_data[ + 'message'] + "\n##### SENT BY: " + "No character but can send pings?" + " TO: " + \ + form.cleaned_data['group'] + " WHEN: " + datetime.datetime.utcnow().strftime( + "%Y-%m-%d %H:%M:%S") + " #####\n##### Replies are NOT monitored #####\n" + group_to_send = form.cleaned_data['group'] + + OpenfireManager.send_broadcast_threaded(group_to_send, message_to_send, ) + + messages.success(request, 'Sent jabber broadcast to %s' % group_to_send) + logger.info("Sent jabber broadcast on behalf of user %s" % request.user) + else: + form = JabberBroadcastForm() + form.fields['group'].choices = allchoices + logger.debug("Generated broadcast form for user %s containing %s groups" % ( + request.user, len(form.fields['group'].choices))) + + context = {'form': form} + return render(request, 'registered/jabberbroadcast.html', context=context) + + +@login_required +@members_and_blues() +def set_jabber_password(request): + logger.debug("set_jabber_password called by user %s" % request.user) + if request.method == 'POST': + logger.debug("Received POST request with form.") + form = ServicePasswordForm(request.POST) + logger.debug("Form is valid: %s" % form.is_valid()) + if form.is_valid() and OpenfireTasks.has_account(request.user): + password = form.cleaned_data['password'] + logger.debug("Form contains password of length %s" % len(password)) + result = OpenfireManager.update_user_pass(request.user.openfire.username, password=password) + if result != "": + logger.info("Successfully set jabber password for user %s" % request.user) + messages.success(request, 'Set jabber password.') + else: + logger.error("Failed to install custom jabber password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your jabber account.') + return redirect("auth_services") + else: + logger.debug("Request is not type POST - providing empty form.") + form = ServicePasswordForm() + + logger.debug("Rendering form for user %s" % request.user) + context = {'form': form, 'service': 'Jabber'} + return render(request, 'registered/service_password.html', context=context) diff --git a/services/modules/phpbb3/__init__.py b/services/modules/phpbb3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/phpbb3/admin.py b/services/modules/phpbb3/admin.py new file mode 100644 index 00000000..97376ea5 --- /dev/null +++ b/services/modules/phpbb3/admin.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from django.contrib import admin +from .models import Phpbb3User + + +class Phpbb3UserAdmin(admin.ModelAdmin): + list_display = ('user', 'username') + search_fields = ('user__username', 'username') + +admin.site.register(Phpbb3User, Phpbb3UserAdmin) diff --git a/services/modules/phpbb3/apps.py b/services/modules/phpbb3/apps.py new file mode 100644 index 00000000..ed8a7bb7 --- /dev/null +++ b/services/modules/phpbb3/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class Phpbb3ServiceConfig(AppConfig): + name = 'phpbb3' diff --git a/services/modules/phpbb3/auth_hooks.py b/services/modules/phpbb3/auth_hooks.py new file mode 100644 index 00000000..df698103 --- /dev/null +++ b/services/modules/phpbb3/auth_hooks.py @@ -0,0 +1,68 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.template.loader import render_to_string + +from services.hooks import ServicesHook +from alliance_auth import hooks + +from .urls import urlpatterns +from .tasks import Phpbb3Tasks + +import logging + +logger = logging.getLogger(__name__) + + +class Phpbb3Service(ServicesHook): + def __init__(self): + ServicesHook.__init__(self) + self.name = 'phpbb3' + self.urlpatterns = urlpatterns + self.service_url = settings.FORUM_URL # TODO: This needs to be renamed at some point... + + @property + def title(self): + return 'phpBB3 Forum' + + def delete_user(self, user, notify_user=False): + logger.debug('Deleting user %s %s account' % (user, self.name)) + return Phpbb3Tasks.delete_user(user, notify_user=notify_user) + + def validate_user(self, user): + logger.debug('Validating user %s %s account' % (user, self.name)) + if Phpbb3Tasks.has_account(user) and not self.service_active_for_user(user): + self.delete_user(user, notify_user=True) + + def update_groups(self, user): + logger.debug('Updating %s groups for %s' % (self.name, user)) + if Phpbb3Tasks.has_account(user): + Phpbb3Tasks.update_groups.delay(user.pk) + + def update_all_groups(self): + logger.debug('Update all %s groups called' % self.name) + Phpbb3Tasks.update_all_groups.delay() + + def service_enabled_members(self): + return settings.ENABLE_AUTH_FORUM or False # TODO: Rename this setting + + def service_enabled_blues(self): + return settings.ENABLE_BLUE_FORUM or False # TODO: Rename this setting + + def render_services_ctrl(self, request): + urls = self.Urls() + urls.auth_activate = 'auth_activate_phpbb3' + urls.auth_deactivate = 'auth_deactivate_phpbb3' + urls.auth_reset_password = 'auth_reset_phpbb3_password' + urls.auth_set_password = 'auth_set_phpbb3_password' + return render_to_string(self.service_ctrl_template, { + 'service_name': self.title, + 'urls': urls, + 'service_url': self.service_url, + 'username': request.user.phpbb3.username if Phpbb3Tasks.has_account(request.user) else '' + }, request=request) + + +@hooks.register('services_hook') +def register_service(): + return Phpbb3Service() diff --git a/services/managers/phpbb3_manager.py b/services/modules/phpbb3/manager.py similarity index 99% rename from services/managers/phpbb3_manager.py rename to services/modules/phpbb3/manager.py index 55f2e883..f3d60caf 100755 --- a/services/managers/phpbb3_manager.py +++ b/services/modules/phpbb3/manager.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals -import os +import random +import string import calendar import re from datetime import datetime @@ -62,7 +63,7 @@ class Phpbb3Manager: @staticmethod def __generate_random_pass(): - return os.urandom(8).encode('hex') + return ''.join([random.choice(string.ascii_letters + string.digits) for n in range(16)]) @staticmethod def __gen_hash(password): diff --git a/services/modules/phpbb3/migrations/0001_initial.py b/services/modules/phpbb3/migrations/0001_initial.py new file mode 100644 index 00000000..50da2682 --- /dev/null +++ b/services/modules/phpbb3/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 03:28 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='Phpbb3User', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='phpbb3', serialize=False, to=settings.AUTH_USER_MODEL)), + ('username', models.CharField(max_length=254)), + ], + ), + ] diff --git a/services/modules/phpbb3/migrations/__init__.py b/services/modules/phpbb3/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/phpbb3/models.py b/services/modules/phpbb3/models.py new file mode 100644 index 00000000..05cfebf5 --- /dev/null +++ b/services/modules/phpbb3/models.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible +from django.db import models + + +@python_2_unicode_compatible +class Phpbb3User(models.Model): + user = models.OneToOneField('auth.User', + primary_key=True, + on_delete=models.CASCADE, + related_name='phpbb3') + username = models.CharField(max_length=254) + + def __str__(self): + return self.username diff --git a/services/modules/phpbb3/tasks.py b/services/modules/phpbb3/tasks.py new file mode 100644 index 00000000..a47719b1 --- /dev/null +++ b/services/modules/phpbb3/tasks.py @@ -0,0 +1,73 @@ +from __future__ import unicode_literals + +from alliance_auth.celeryapp import app +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from notifications import notify + +from .manager import Phpbb3Manager +from .models import Phpbb3User + +import logging + +logger = logging.getLogger(__name__) + + +class Phpbb3Tasks: + def __init__(self): + pass + + @classmethod + def delete_user(cls, user, notify_user=False): + if cls.has_account(user): + logger.debug("User %s has forum account %s. Deleting." % (user, user.phpbb3.username)) + if Phpbb3Manager.disable_user(user.phpbb3.username): + user.phpbb3.delete() + if notify_user: + notify(user, 'Forum Account Disabled', level='danger') + return True + return False + + @staticmethod + def has_account(user): + try: + return user.phpbb3.username != '' + except ObjectDoesNotExist: + return False + + @staticmethod + @app.task(bind=True) + def update_groups(self, pk): + user = User.objects.get(pk=pk) + logger.debug("Updating phpbb3 groups for user %s" % user) + if Phpbb3Tasks.has_account(user): + groups = [] + for group in user.groups.all(): + groups.append(str(group.name)) + if len(groups) == 0: + groups.append('empty') + logger.debug("Updating user %s phpbb3 groups to %s" % (user, groups)) + try: + Phpbb3Manager.update_groups(user.phpbb3.username, groups) + except: + logger.exception("Phpbb group sync failed for %s, retrying in 10 mins" % user) + raise self.retry(countdown=60 * 10) + logger.debug("Updated user %s phpbb3 groups." % user) + else: + logger.debug("User does not have a Phpbb3 account") + + @staticmethod + @app.task + def update_all_groups(): + logger.debug("Updating ALL phpbb3 groups") + for user in Phpbb3User.objects.exclude(username__exact=''): + Phpbb3Tasks.update_groups.delay(user.user_id) + + @staticmethod + def disable(): + if settings.ENABLE_AUTH_FORUM: + logger.warn("ENABLE_AUTH_FORUM still True, after disabling users will still be able to create forum accounts") + if settings.ENABLE_BLUE_FORUM: + logger.warn("ENABLE_BLUE_FORUM still True, after disabling blues will still be able to create forum accounts") + Phpbb3User.objects.all().delete() diff --git a/services/modules/phpbb3/tests.py b/services/modules/phpbb3/tests.py new file mode 100644 index 00000000..4ee24c60 --- /dev/null +++ b/services/modules/phpbb3/tests.py @@ -0,0 +1,212 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase, RequestFactory +from django.conf import settings +from django import urls +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist + +from alliance_auth.tests.auth_utils import AuthUtils + +from .auth_hooks import Phpbb3Service +from .models import Phpbb3User +from .tasks import Phpbb3Tasks + +MODULE_PATH = 'services.modules.phpbb3' + + +class Phpbb3HooksTestCase(TestCase): + def setUp(self): + self.member = 'member_user' + member = AuthUtils.create_member(self.member) + Phpbb3User.objects.create(user=member, username=self.member) + self.blue = 'blue_user' + blue = AuthUtils.create_blue(self.blue) + Phpbb3User.objects.create(user=blue, username=self.blue) + self.none_user = 'none_user' + none_user = AuthUtils.create_user(self.none_user) + self.service = Phpbb3Service + + def test_has_account(self): + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(Phpbb3Tasks.has_account(member)) + self.assertTrue(Phpbb3Tasks.has_account(blue)) + self.assertFalse(Phpbb3Tasks.has_account(none_user)) + + def test_service_enabled(self): + service = self.service() + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(service.service_enabled_members()) + self.assertTrue(service.service_enabled_blues()) + + self.assertEqual(service.service_active_for_user(member), settings.ENABLE_AUTH_FORUM) + self.assertEqual(service.service_active_for_user(blue), settings.ENABLE_BLUE_FORUM) + self.assertFalse(service.service_active_for_user(none_user)) + + @mock.patch(MODULE_PATH + '.tasks.Phpbb3Manager') + def test_update_all_groups(self, manager): + service = self.service() + service.update_all_groups() + # Check member and blue user have groups updated + self.assertTrue(manager.update_groups.called) + self.assertEqual(manager.update_groups.call_count, 2) + + def test_update_groups(self): + # Check member has Member group updated + with mock.patch(MODULE_PATH + '.tasks.Phpbb3Manager') as manager: + service = self.service() + member = User.objects.get(username=self.member) + service.update_groups(member) + self.assertTrue(manager.update_groups.called) + args, kwargs = manager.update_groups.call_args + user_id, groups = args + self.assertIn(settings.DEFAULT_AUTH_GROUP, groups) + self.assertEqual(user_id, member.phpbb3.username) + + # Check none user does not have groups updated + with mock.patch(MODULE_PATH + '.tasks.Phpbb3Manager') as manager: + service = self.service() + none_user = User.objects.get(username=self.none_user) + service.update_groups(none_user) + self.assertFalse(manager.update_groups.called) + + @mock.patch(MODULE_PATH + '.tasks.Phpbb3Manager') + def test_validate_user(self, manager): + service = self.service() + # Test member is not deleted + member = User.objects.get(username=self.member) + service.validate_user(member) + self.assertTrue(member.phpbb3) + + # Test none user is deleted + none_user = User.objects.get(username=self.none_user) + Phpbb3User.objects.create(user=none_user, username='abc123') + service.validate_user(none_user) + self.assertTrue(manager.disable_user.called) + with self.assertRaises(ObjectDoesNotExist): + none_phpbb3 = User.objects.get(username=self.none_user).phpbb3 + + @mock.patch(MODULE_PATH + '.tasks.Phpbb3Manager') + def test_delete_user(self, manager): + member = User.objects.get(username=self.member) + + service = self.service() + result = service.delete_user(member) + + self.assertTrue(result) + self.assertTrue(manager.disable_user.called) + with self.assertRaises(ObjectDoesNotExist): + phpbb3_user = User.objects.get(username=self.member).phpbb3 + + def test_render_services_ctrl(self): + service = self.service() + member = User.objects.get(username=self.member) + request = RequestFactory().get('/en/services/') + request.user = member + + response = service.render_services_ctrl(request) + self.assertTemplateUsed(service.service_ctrl_template) + self.assertIn(urls.reverse('auth_deactivate_phpbb3'), response) + self.assertIn(urls.reverse('auth_reset_phpbb3_password'), response) + self.assertIn(urls.reverse('auth_set_phpbb3_password'), response) + + # Test register becomes available + member.phpbb3.delete() + member = User.objects.get(username=self.member) + request.user = member + response = service.render_services_ctrl(request) + self.assertIn(urls.reverse('auth_activate_phpbb3'), response) + + +class Phpbb3ViewsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.member.set_password('password') + self.member.email = 'auth_member@example.com' + self.member.save() + AuthUtils.add_main_character(self.member, 'auth_member', '12345', corp_id='111', corp_name='Test Corporation') + + def login(self): + self.client.login(username=self.member.username, password='password') + + @mock.patch(MODULE_PATH + '.tasks.Phpbb3Manager') + @mock.patch(MODULE_PATH + '.views.Phpbb3Manager') + def test_activate(self, manager, tasks_manager): + self.login() + expected_username = 'auth_member' + manager.add_user.return_value = (expected_username, 'abc123') + + response = self.client.get(urls.reverse('auth_activate_phpbb3')) + + self.assertTrue(manager.add_user.called) + self.assertTrue(tasks_manager.update_groups.called) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed('registered/service_credentials.html') + self.assertContains(response, expected_username) + phpbb3_user = Phpbb3User.objects.get(user=self.member) + self.assertEqual(phpbb3_user.username, expected_username) + + @mock.patch(MODULE_PATH + '.tasks.Phpbb3Manager') + def test_deactivate(self, manager): + self.login() + Phpbb3User.objects.create(user=self.member, username='some member') + + response = self.client.get(urls.reverse('auth_deactivate_phpbb3')) + + self.assertTrue(manager.disable_user.called) + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + with self.assertRaises(ObjectDoesNotExist): + phpbb3_user = User.objects.get(pk=self.member.pk).phpbb3 + + @mock.patch(MODULE_PATH + '.views.Phpbb3Manager') + def test_set_password(self, manager): + self.login() + Phpbb3User.objects.create(user=self.member, username='some member') + + response = self.client.post(urls.reverse('auth_set_phpbb3_password'), data={'password': '1234asdf'}) + + self.assertTrue(manager.update_user_password.called) + args, kwargs = manager.update_user_password.call_args + self.assertEqual(kwargs['password'], '1234asdf') + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + + @mock.patch(MODULE_PATH + '.views.Phpbb3Manager') + def test_reset_password(self, manager): + self.login() + Phpbb3User.objects.create(user=self.member, username='some member') + + manager.update_user_password.return_value = 'hunter2' + + response = self.client.get(urls.reverse('auth_reset_phpbb3_password')) + + self.assertTemplateUsed(response, 'registered/service_credentials.html') + self.assertContains(response, 'some member') + self.assertContains(response, 'hunter2') + + +class Phpbb3ManagerTestCase(TestCase): + def setUp(self): + from .manager import Phpbb3Manager + self.manager = Phpbb3Manager + + def test_generate_random_password(self): + password = self.manager._Phpbb3Manager__generate_random_pass() + + self.assertEqual(len(password), 16) + self.assertIsInstance(password, type('')) + + def test_gen_pwhash(self): + pwhash = self.manager._Phpbb3Manager__gen_hash('test') + + self.assertIsInstance(pwhash, str) diff --git a/services/modules/phpbb3/urls.py b/services/modules/phpbb3/urls.py new file mode 100644 index 00000000..a64f4cc1 --- /dev/null +++ b/services/modules/phpbb3/urls.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +from django.conf.urls import url, include + +from . import views + +module_urls = [ + # Forum Service Control + url(r'^activate/$', views.activate_forum, name='auth_activate_phpbb3'), + url(r'^deactivate/$', views.deactivate_forum, name='auth_deactivate_phpbb3'), + url(r'^reset_password/$', views.reset_forum_password, name='auth_reset_phpbb3_password'), + url(r'^set_password/$', views.set_forum_password, name='auth_set_phpbb3_password'), +] + +urlpatterns = [ + url(r'^phpbb3/', include(module_urls)) +] diff --git a/services/modules/phpbb3/views.py b/services/modules/phpbb3/views.py new file mode 100644 index 00000000..2c1b21db --- /dev/null +++ b/services/modules/phpbb3/views.py @@ -0,0 +1,112 @@ +from __future__ import unicode_literals + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect + +from authentication.decorators import members_and_blues +from eveonline.managers import EveManager +from services.forms import ServicePasswordForm + +from .manager import Phpbb3Manager +from .tasks import Phpbb3Tasks +from .models import Phpbb3User + +import logging + +logger = logging.getLogger(__name__) + + +@login_required +@members_and_blues() +def activate_forum(request): + logger.debug("activate_forum called by user %s" % request.user) + # Valid now we get the main characters + character = EveManager.get_main_character(request.user) + logger.debug("Adding phpbb user for user %s with main character %s" % (request.user, character)) + result = Phpbb3Manager.add_user(character.character_name, request.user.email, ['REGISTERED'], + character.character_id) + # if empty we failed + if result[0] != "": + Phpbb3User.objects.update_or_create(user=request.user, defaults={'username': result[0]}) + logger.debug("Updated authserviceinfo for user %s with forum credentials. Updating groups." % request.user) + Phpbb3Tasks.update_groups.delay(request.user.pk) + logger.info("Successfully activated forum for user %s" % request.user) + messages.success(request, 'Activated forum account.') + credentials = { + 'username': result[0], + 'password': result[1], + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'Forum'}) + else: + logger.error("Unsuccessful attempt to activate forum for user %s" % request.user) + messages.error(request, 'An error occurred while processing your forum account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def deactivate_forum(request): + logger.debug("deactivate_forum called by user %s" % request.user) + # false we failed + if Phpbb3Tasks.delete_user(request.user): + logger.info("Successfully deactivated forum for user %s" % request.user) + messages.success(request, 'Deactivated forum account.') + else: + logger.error("Unsuccessful attempt to activate forum for user %s" % request.user) + messages.error(request, 'An error occurred while processing your forum account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def reset_forum_password(request): + logger.debug("reset_forum_password called by user %s" % request.user) + if Phpbb3Tasks.has_account(request.user): + character = EveManager.get_main_character(request.user) + result = Phpbb3Manager.update_user_password(request.user.phpbb3.username, character.character_id) + # false we failed + if result != "": + logger.info("Successfully reset forum password for user %s" % request.user) + messages.success(request, 'Reset forum password.') + credentials = { + 'username': request.user.phpbb3.username, + 'password': result, + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'Forum'}) + + logger.error("Unsuccessful attempt to reset forum password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your forum account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def set_forum_password(request): + logger.debug("set_forum_password called by user %s" % request.user) + if request.method == 'POST': + logger.debug("Received POST request with form.") + form = ServicePasswordForm(request.POST) + logger.debug("Form is valid: %s" % form.is_valid()) + if form.is_valid() and Phpbb3Tasks.has_account(request.user): + password = form.cleaned_data['password'] + logger.debug("Form contains password of length %s" % len(password)) + character = EveManager.get_main_character(request.user) + result = Phpbb3Manager.update_user_password(request.user.phpbb3.username, character.character_id, + password=password) + if result != "": + logger.info("Successfully set forum password for user %s" % request.user) + messages.success(request, 'Set forum password.') + else: + logger.error("Failed to install custom forum password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your forum account.') + return redirect("auth_services") + else: + logger.debug("Request is not type POST - providing empty form.") + form = ServicePasswordForm() + + logger.debug("Rendering form for user %s" % request.user) + context = {'form': form, 'service': 'Forum'} + return render(request, 'registered/service_password.html', context=context) diff --git a/services/modules/smf/__init__.py b/services/modules/smf/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/smf/admin.py b/services/modules/smf/admin.py new file mode 100644 index 00000000..a4291319 --- /dev/null +++ b/services/modules/smf/admin.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from django.contrib import admin +from .models import SmfUser + + +class SmfUserAdmin(admin.ModelAdmin): + list_display = ('user', 'username') + search_fields = ('user__username', 'username') + +admin.site.register(SmfUser, SmfUserAdmin) diff --git a/services/modules/smf/apps.py b/services/modules/smf/apps.py new file mode 100644 index 00000000..66059295 --- /dev/null +++ b/services/modules/smf/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class SmfServiceConfig(AppConfig): + name = 'smf' diff --git a/services/modules/smf/auth_hooks.py b/services/modules/smf/auth_hooks.py new file mode 100644 index 00000000..b6e30652 --- /dev/null +++ b/services/modules/smf/auth_hooks.py @@ -0,0 +1,68 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.template.loader import render_to_string + +from services.hooks import ServicesHook +from alliance_auth import hooks + +from .urls import urlpatterns +from .tasks import SmfTasks + +import logging + +logger = logging.getLogger(__name__) + + +class SmfService(ServicesHook): + def __init__(self): + ServicesHook.__init__(self) + self.name = 'smf' + self.urlpatterns = urlpatterns + self.service_url = settings.SMF_URL + + @property + def title(self): + return 'SMF Forums' + + def delete_user(self, user, notify_user=False): + logger.debug('Deleting user %s %s account' % (user, self.name)) + return SmfTasks.delete_user(user, notify_user=notify_user) + + def validate_user(self, user): + logger.debug('Validating user %s %s account' % (user, self.name)) + if SmfTasks.has_account(user) and not self.service_active_for_user(user): + self.delete_user(user) + + def update_groups(self, user): + logger.debug('Updating %s groups for %s' % (self.name, user)) + if SmfTasks.has_account(user): + SmfTasks.update_groups.delay(user.pk) + + def update_all_groups(self): + logger.debug('Update all %s groups called' % self.name) + SmfTasks.update_all_groups.delay() + + def service_enabled_members(self): + return settings.ENABLE_AUTH_SMF or False + + def service_enabled_blues(self): + return settings.ENABLE_BLUE_SMF or False + + def render_services_ctrl(self, request): + urls = self.Urls() + urls.auth_activate = 'auth_activate_smf' + urls.auth_deactivate = 'auth_deactivate_smf' + urls.auth_reset_password = 'auth_reset_smf_password' + urls.auth_set_password = 'auth_set_smf_password' + return render_to_string(self.service_ctrl_template, { + 'service_name': self.title, + 'urls': urls, + 'service_url': self.service_url, + 'username': request.user.smf.username if SmfTasks.has_account(request.user) else '' + }, request=request) + + +@hooks.register('services_hook') +def register_service(): + return SmfService() diff --git a/services/managers/smf_manager.py b/services/modules/smf/manager.py similarity index 66% rename from services/managers/smf_manager.py rename to services/modules/smf/manager.py index a8352c7b..36679b46 100644 --- a/services/managers/smf_manager.py +++ b/services/modules/smf/manager.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals -import os +import random +import string import calendar from datetime import datetime import hashlib @@ -12,7 +13,7 @@ from django.conf import settings logger = logging.getLogger(__name__) -class smfManager: +class SmfManager: def __init__(self): pass @@ -49,11 +50,11 @@ class smfManager: @staticmethod def generate_random_pass(): - return os.urandom(8).encode('hex') + return ''.join([random.choice(string.ascii_letters + string.digits) for n in range(16)]) @staticmethod def gen_hash(username_clean, passwd): - return hashlib.sha1(username_clean + passwd).hexdigest() + return hashlib.sha1((username_clean + passwd).encode('utf-8')).hexdigest() @staticmethod def santatize_username(username): @@ -67,28 +68,28 @@ class smfManager: unixtime = calendar.timegm(d.utctimetuple()) return unixtime - @staticmethod - def create_group(groupname): + @classmethod + def create_group(cls, groupname): logger.debug("Creating smf group %s" % groupname) cursor = connections['smf'].cursor() - cursor.execute(smfManager.SQL_ADD_GROUP, [groupname, groupname]) + cursor.execute(cls.SQL_ADD_GROUP, [groupname, groupname]) logger.info("Created smf group %s" % groupname) - return smfManager.get_group_id(groupname) + return cls.get_group_id(groupname) - @staticmethod - def get_group_id(groupname): + @classmethod + def get_group_id(cls, groupname): logger.debug("Getting smf group id for groupname %s" % groupname) cursor = connections['smf'].cursor() - cursor.execute(smfManager.SQL_GET_GROUP_ID, [groupname]) + cursor.execute(cls.SQL_GET_GROUP_ID, [groupname]) row = cursor.fetchone() logger.debug("Got smf group id %s for groupname %s" % (row[0], groupname)) return row[0] - @staticmethod - def check_user(username): + @classmethod + def check_user(cls, username): logger.debug("Checking smf username %s" % username) cursor = connections['smf'].cursor() - cursor.execute(smfManager.SQL_USER_ID_FROM_USERNAME, [smfManager.santatize_username(username)]) + cursor.execute(cls.SQL_USER_ID_FROM_USERNAME, [cls.santatize_username(username)]) row = cursor.fetchone() if row: logger.debug("Found user %s on smf" % username) @@ -96,19 +97,19 @@ class smfManager: logger.debug("User %s not found on smf" % username) return False - @staticmethod - def add_avatar(member_name, characterid): + @classmethod + def add_avatar(cls, member_name, characterid): logger.debug("Adding EVE character id %s portrait as smf avatar for user %s" % (characterid, member_name)) avatar_url = "https://image.eveonline.com/Character/" + characterid + "_64.jpg" cursor = connections['smf'].cursor() - id_member = smfManager.get_user_id(member_name) - cursor.execute(smfManager.SQL_ADD_USER_AVATAR, [avatar_url, id_member]) + id_member = cls.get_user_id(member_name) + cursor.execute(cls.SQL_ADD_USER_AVATAR, [avatar_url, id_member]) - @staticmethod - def get_user_id(username): + @classmethod + def get_user_id(cls, username): logger.debug("Getting smf user id for username %s" % username) cursor = connections['smf'].cursor() - cursor.execute(smfManager.SQL_USER_ID_FROM_USERNAME, [username]) + cursor.execute(cls.SQL_USER_ID_FROM_USERNAME, [username]) row = cursor.fetchone() if row is not None: logger.debug("Got smf user id %s for username %s" % (row[0], username)) @@ -117,11 +118,11 @@ class smfManager: logger.error("username %s not found on smf. Unable to determine user id ." % username) return None - @staticmethod - def get_all_groups(): + @classmethod + def get_all_groups(cls): logger.debug("Getting all smf groups.") cursor = connections['smf'].cursor() - cursor.execute(smfManager.SQL_GET_ALL_GROUPS) + cursor.execute(cls.SQL_GET_ALL_GROUPS) rows = cursor.fetchall() out = {} for row in rows: @@ -129,137 +130,137 @@ class smfManager: logger.debug("Got smf groups %s" % out) return out - @staticmethod - def get_user_groups(userid): + @classmethod + def get_user_groups(cls, userid): logger.debug("Getting smf user id %s groups" % userid) cursor = connections['smf'].cursor() - cursor.execute(smfManager.SQL_GET_USER_GROUPS, [userid]) + cursor.execute(cls.SQL_GET_USER_GROUPS, [userid]) out = [row[0] for row in cursor.fetchall()] logger.debug("Got user %s smf groups %s" % (userid, out)) return out - @staticmethod - def add_user(username, email_address, groups, characterid): + @classmethod + def add_user(cls, username, email_address, groups, characterid): logger.debug("Adding smf user with member_name %s, email_address %s, characterid %s" % ( username, email_address, characterid)) cursor = connections['smf'].cursor() - username_clean = smfManager.santatize_username(username) - passwd = smfManager.generate_random_pass() - pwhash = smfManager.gen_hash(username_clean, passwd) + username_clean = cls.santatize_username(username) + passwd = cls.generate_random_pass() + pwhash = cls.gen_hash(username_clean, passwd) logger.debug("Proceeding to add smf user %s and pwhash starting with %s" % (username, pwhash[0:5])) - register_date = smfManager.get_current_utc_date() + register_date = cls.get_current_utc_date() # check if the username was simply revoked - if smfManager.check_user(username) is True: + if cls.check_user(username) is True: logger.warn("Unable to add smf user with username %s - already exists. Updating user instead." % username) - smfManager.__update_user_info(username_clean, email_address, pwhash) + cls.__update_user_info(username_clean, email_address, pwhash) else: try: - cursor.execute(smfManager.SQL_ADD_USER, + cursor.execute(cls.SQL_ADD_USER, [username_clean, passwd, email_address, register_date, username_clean]) - smfManager.add_avatar(username_clean, characterid) + cls.add_avatar(username_clean, characterid) logger.info("Added smf member_name %s" % username_clean) - smfManager.update_groups(username_clean, groups) + cls.update_groups(username_clean, groups) except: logger.warn("Unable to add smf user %s" % username_clean) pass return username_clean, passwd - @staticmethod - def __update_user_info(username, email_address, passwd): + @classmethod + def __update_user_info(cls, username, email_address, passwd): logger.debug( "Updating smf user %s info: username %s password of length %s" % (username, email_address, len(passwd))) cursor = connections['smf'].cursor() try: - cursor.execute(smfManager.SQL_DIS_USER, [email_address, passwd, username]) + cursor.execute(cls.SQL_DIS_USER, [email_address, passwd, username]) logger.info("Updated smf user %s info" % username) except: logger.exception("Unable to update smf user %s info." % username) pass - @staticmethod - def delete_user(username): + @classmethod + def delete_user(cls, username): logger.debug("Deleting smf user %s" % username) cursor = connections['smf'].cursor() - if smfManager.check_user(username): - cursor.execute(smfManager.SQL_DEL_USER, [username]) + if cls.check_user(username): + cursor.execute(cls.SQL_DEL_USER, [username]) logger.info("Deleted smf user %s" % username) return True logger.error("Unable to delete smf user %s - user not found on smf." % username) return False - @staticmethod - def update_groups(username, groups): - userid = smfManager.get_user_id(username) + @classmethod + def update_groups(cls, username, groups): + userid = cls.get_user_id(username) logger.debug("Updating smf user %s with id %s groups %s" % (username, userid, groups)) if userid is not None: - forum_groups = smfManager.get_all_groups() - user_groups = set(smfManager.get_user_groups(userid)) - act_groups = set([smfManager._sanitize_groupname(g) for g in groups]) + forum_groups = cls.get_all_groups() + user_groups = set(cls.get_user_groups(userid)) + act_groups = set([cls._sanitize_groupname(g) for g in groups]) addgroups = act_groups - user_groups remgroups = user_groups - act_groups logger.info("Updating smf user %s groups - adding %s, removing %s" % (username, addgroups, remgroups)) act_group_id = set() for g in addgroups: if g not in forum_groups: - forum_groups[g] = smfManager.create_group(g) - act_group_id.add(str(smfManager.get_group_id(g))) + forum_groups[g] = cls.create_group(g) + act_group_id.add(str(cls.get_group_id(g))) string_groups = ','.join(act_group_id) - smfManager.add_user_to_group(userid, string_groups) + cls.add_user_to_group(userid, string_groups) - @staticmethod - def add_user_to_group(userid, groupid): + @classmethod + def add_user_to_group(cls, userid, groupid): logger.debug("Adding smf user id %s to group id %s" % (userid, groupid)) try: cursor = connections['smf'].cursor() - cursor.execute(smfManager.SQL_ADD_USER_GROUP, [groupid, userid]) + cursor.execute(cls.SQL_ADD_USER_GROUP, [groupid, userid]) logger.info("Added smf user id %s to group id %s" % (userid, groupid)) except: logger.exception("Unable to add smf user id %s to group id %s" % (userid, groupid)) pass - @staticmethod - def remove_user_from_group(userid, groupid): + @classmethod + def remove_user_from_group(cls, userid, groupid): logger.debug("Removing smf user id %s from group id %s" % (userid, groupid)) try: cursor = connections['smf'].cursor() - cursor.execute(smfManager.SQL_REMOVE_USER_GROUP, [groupid, userid]) + cursor.execute(cls.SQL_REMOVE_USER_GROUP, [groupid, userid]) logger.info("Removed smf user id %s from group id %s" % (userid, groupid)) except: logger.exception("Unable to remove smf user id %s from group id %s" % (userid, groupid)) pass - @staticmethod - def disable_user(username): + @classmethod + def disable_user(cls, username): logger.debug("Disabling smf user %s" % username) cursor = connections['smf'].cursor() - password = smfManager.generate_random_pass() + password = cls.generate_random_pass() revoke_email = "revoked@" + settings.DOMAIN try: - pwhash = smfManager.gen_hash(username, password) - cursor.execute(smfManager.SQL_DIS_USER, [revoke_email, pwhash, username]) - smfManager.get_user_id(username) - smfManager.update_groups(username, []) + pwhash = cls.gen_hash(username, password) + cursor.execute(cls.SQL_DIS_USER, [revoke_email, pwhash, username]) + cls.get_user_id(username) + cls.update_groups(username, []) logger.info("Disabled smf user %s" % username) return True except TypeError: logger.exception("TypeError occured while disabling user %s - failed to disable." % username) return False - @staticmethod - def update_user_password(username, characterid, password=None): + @classmethod + def update_user_password(cls, username, characterid, password=None): logger.debug("Updating smf user %s password" % username) cursor = connections['smf'].cursor() if not password: - password = smfManager.generate_random_pass() - if smfManager.check_user(username): - username_clean = smfManager.santatize_username(username) - pwhash = smfManager.gen_hash(username_clean, password) + password = cls.generate_random_pass() + if cls.check_user(username): + username_clean = cls.santatize_username(username) + pwhash = cls.gen_hash(username_clean, password) logger.debug( "Proceeding to update smf user %s password with pwhash starting with %s" % (username, pwhash[0:5])) - cursor.execute(smfManager.SQL_UPDATE_USER_PASSWORD, [pwhash, username]) - smfManager.add_avatar(username, characterid) + cursor.execute(cls.SQL_UPDATE_USER_PASSWORD, [pwhash, username]) + cls.add_avatar(username, characterid) logger.info("Updated smf user %s password." % username) return password logger.error("Unable to update smf user %s password - user not found on smf." % username) diff --git a/services/modules/smf/migrations/0001_initial.py b/services/modules/smf/migrations/0001_initial.py new file mode 100644 index 00000000..23d14ace --- /dev/null +++ b/services/modules/smf/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 03:28 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='SmfUser', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='smf', serialize=False, to=settings.AUTH_USER_MODEL)), + ('username', models.CharField(max_length=254)), + ], + ), + ] diff --git a/services/modules/smf/migrations/__init__.py b/services/modules/smf/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/smf/models.py b/services/modules/smf/models.py new file mode 100644 index 00000000..726476a5 --- /dev/null +++ b/services/modules/smf/models.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible +from django.db import models + + +@python_2_unicode_compatible +class SmfUser(models.Model): + user = models.OneToOneField('auth.User', + primary_key=True, + on_delete=models.CASCADE, + related_name='smf') + username = models.CharField(max_length=254) + + def __str__(self): + return self.username diff --git a/services/modules/smf/tasks.py b/services/modules/smf/tasks.py new file mode 100644 index 00000000..e0580c55 --- /dev/null +++ b/services/modules/smf/tasks.py @@ -0,0 +1,75 @@ +from __future__ import unicode_literals + +import logging + +from alliance_auth.celeryapp import app +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from notifications import notify + +from .manager import SmfManager +from .models import SmfUser + +logger = logging.getLogger(__name__) + + +class SmfTasks: + def __init__(self): + pass + + @classmethod + def delete_user(cls, user, notify_user=False): + if cls.has_account(user): + logger.debug("User %s has a SMF account %s. Deleting." % (user, user.smf.username)) + SmfManager.disable_user(user.smf.username) + user.smf.delete() + if notify_user: + notify(user, "SMF Account Disabled", level='danger') + return True + return False + + @staticmethod + def has_account(user): + try: + return user.smf.username != '' + except ObjectDoesNotExist: + return False + + @classmethod + def disable(cls): + if settings.ENABLE_AUTH_SMF: + logger.warn( + "ENABLE_AUTH_SMF still True, after disabling users will still be able to link smf accounts") + if settings.ENABLE_BLUE_SMF: + logger.warn( + "ENABLE_BLUE_SMF still True, after disabling blues will still be able to link smf accounts") + SmfUser.objects.all().delete() + + @staticmethod + @app.task(bind=True) + def update_groups(self, pk): + user = User.objects.get(pk=pk) + logger.debug("Updating smf groups for user %s" % user) + if SmfTasks.has_account(user): + groups = [] + for group in user.groups.all(): + groups.append(str(group.name)) + if len(groups) == 0: + groups.append('empty') + logger.debug("Updating user %s smf groups to %s" % (user, groups)) + try: + SmfManager.update_groups(user.smf.username, groups) + except: + logger.exception("smf group sync failed for %s, retrying in 10 mins" % user) + raise self.retry(countdown=60 * 10) + logger.debug("Updated user %s smf groups." % user) + else: + logger.debug("User does not have an smf account") + + @staticmethod + @app.task + def update_all_groups(): + logger.debug("Updating ALL smf groups") + for user in SmfUser.objects.exclude(username__exact=''): + SmfTasks.update_groups.delay(user.user_id) diff --git a/services/modules/smf/tests.py b/services/modules/smf/tests.py new file mode 100644 index 00000000..bace9368 --- /dev/null +++ b/services/modules/smf/tests.py @@ -0,0 +1,212 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase, RequestFactory +from django.conf import settings +from django import urls +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist + +from alliance_auth.tests.auth_utils import AuthUtils + +from .auth_hooks import SmfService +from .models import SmfUser +from .tasks import SmfTasks + +MODULE_PATH = 'services.modules.smf' + + +class SmfHooksTestCase(TestCase): + def setUp(self): + self.member = 'member_user' + member = AuthUtils.create_member(self.member) + SmfUser.objects.create(user=member, username=self.member) + self.blue = 'blue_user' + blue = AuthUtils.create_blue(self.blue) + SmfUser.objects.create(user=blue, username=self.blue) + self.none_user = 'none_user' + none_user = AuthUtils.create_user(self.none_user) + self.service = SmfService + + def test_has_account(self): + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(SmfTasks.has_account(member)) + self.assertTrue(SmfTasks.has_account(blue)) + self.assertFalse(SmfTasks.has_account(none_user)) + + def test_service_enabled(self): + service = self.service() + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(service.service_enabled_members()) + self.assertTrue(service.service_enabled_blues()) + + self.assertEqual(service.service_active_for_user(member), settings.ENABLE_AUTH_SMF) + self.assertEqual(service.service_active_for_user(blue), settings.ENABLE_BLUE_SMF) + self.assertFalse(service.service_active_for_user(none_user)) + + @mock.patch(MODULE_PATH + '.tasks.SmfManager') + def test_update_all_groups(self, manager): + service = self.service() + service.update_all_groups() + # Check member and blue user have groups updated + self.assertTrue(manager.update_groups.called) + self.assertEqual(manager.update_groups.call_count, 2) + + def test_update_groups(self): + # Check member has Member group updated + with mock.patch(MODULE_PATH + '.tasks.SmfManager') as manager: + service = self.service() + member = User.objects.get(username=self.member) + service.update_groups(member) + self.assertTrue(manager.update_groups.called) + args, kwargs = manager.update_groups.call_args + user_id, groups = args + self.assertIn(settings.DEFAULT_AUTH_GROUP, groups) + self.assertEqual(user_id, member.smf.username) + + # Check none user does not have groups updated + with mock.patch(MODULE_PATH + '.tasks.SmfManager') as manager: + service = self.service() + none_user = User.objects.get(username=self.none_user) + service.update_groups(none_user) + self.assertFalse(manager.update_groups.called) + + @mock.patch(MODULE_PATH + '.tasks.SmfManager') + def test_validate_user(self, manager): + service = self.service() + # Test member is not deleted + member = User.objects.get(username=self.member) + service.validate_user(member) + self.assertTrue(member.smf) + + # Test none user is deleted + none_user = User.objects.get(username=self.none_user) + SmfUser.objects.create(user=none_user, username='abc123') + service.validate_user(none_user) + self.assertTrue(manager.disable_user.called) + with self.assertRaises(ObjectDoesNotExist): + none_smf = User.objects.get(username=self.none_user).smf + + @mock.patch(MODULE_PATH + '.tasks.SmfManager') + def test_delete_user(self, manager): + member = User.objects.get(username=self.member) + + service = self.service() + result = service.delete_user(member) + + self.assertTrue(result) + self.assertTrue(manager.disable_user.called) + with self.assertRaises(ObjectDoesNotExist): + smf_user = User.objects.get(username=self.member).smf + + def test_render_services_ctrl(self): + service = self.service() + member = User.objects.get(username=self.member) + request = RequestFactory().get('/en/services/') + request.user = member + + response = service.render_services_ctrl(request) + self.assertTemplateUsed(service.service_ctrl_template) + self.assertIn(urls.reverse('auth_deactivate_smf'), response) + self.assertIn(urls.reverse('auth_reset_smf_password'), response) + self.assertIn(urls.reverse('auth_set_smf_password'), response) + + # Test register becomes available + member.smf.delete() + member = User.objects.get(username=self.member) + request.user = member + response = service.render_services_ctrl(request) + self.assertIn(urls.reverse('auth_activate_smf'), response) + + +class SmfViewsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.member.set_password('password') + self.member.email = 'auth_member@example.com' + self.member.save() + AuthUtils.add_main_character(self.member, 'auth_member', '12345', corp_id='111', corp_name='Test Corporation') + + def login(self): + self.client.login(username=self.member.username, password='password') + + @mock.patch(MODULE_PATH + '.tasks.SmfManager') + @mock.patch(MODULE_PATH + '.views.SmfManager') + def test_activate(self, manager, tasks_manager): + self.login() + expected_username = 'auth_member' + manager.add_user.return_value = (expected_username, 'abc123') + + response = self.client.get(urls.reverse('auth_activate_smf')) + + self.assertTrue(manager.add_user.called) + self.assertTrue(tasks_manager.update_groups.called) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed('registered/service_credentials.html') + self.assertContains(response, expected_username) + smf_user = SmfUser.objects.get(user=self.member) + self.assertEqual(smf_user.username, expected_username) + + @mock.patch(MODULE_PATH + '.tasks.SmfManager') + def test_deactivate(self, manager): + self.login() + SmfUser.objects.create(user=self.member, username='some member') + + response = self.client.get(urls.reverse('auth_deactivate_smf')) + + self.assertTrue(manager.disable_user.called) + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + with self.assertRaises(ObjectDoesNotExist): + smf_user = User.objects.get(pk=self.member.pk).smf + + @mock.patch(MODULE_PATH + '.views.SmfManager') + def test_set_password(self, manager): + self.login() + SmfUser.objects.create(user=self.member, username='some member') + + response = self.client.post(urls.reverse('auth_set_smf_password'), data={'password': '1234asdf'}) + + self.assertTrue(manager.update_user_password.called) + args, kwargs = manager.update_user_password.call_args + self.assertEqual(kwargs['password'], '1234asdf') + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + + @mock.patch(MODULE_PATH + '.views.SmfManager') + def test_reset_password(self, manager): + self.login() + SmfUser.objects.create(user=self.member, username='some member') + + manager.update_user_password.return_value = 'hunter2' + + response = self.client.get(urls.reverse('auth_reset_smf_password')) + + self.assertTemplateUsed(response, 'registered/service_credentials.html') + self.assertContains(response, 'some member') + self.assertContains(response, 'hunter2') + + +class SmfManagerTestCase(TestCase): + def setUp(self): + from .manager import SmfManager + self.manager = SmfManager + + def test_generate_random_password(self): + password = self.manager.generate_random_pass() + + self.assertEqual(len(password), 16) + self.assertIsInstance(password, type('')) + + def test_gen_hash(self): + pwhash = self.manager.gen_hash('username', 'test') + + self.assertEqual(pwhash, 'b6d21d37de84db76746b1c45696a00f9ce4f86fd') diff --git a/services/modules/smf/urls.py b/services/modules/smf/urls.py new file mode 100644 index 00000000..6fb2310e --- /dev/null +++ b/services/modules/smf/urls.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +from django.conf.urls import url, include + +from . import views + +module_urls = [ + # SMF Service Control + url(r'^activate/$', views.activate_smf, name='auth_activate_smf'), + url(r'^deactivate/$', views.deactivate_smf, name='auth_deactivate_smf'), + url(r'^reset_password/$', views.reset_smf_password, name='auth_reset_smf_password'), + url(r'^set_password/$', views.set_smf_password, name='auth_set_smf_password'), +] + +urlpatterns = [ + url(r'^smf/', include(module_urls)), +] diff --git a/services/modules/smf/views.py b/services/modules/smf/views.py new file mode 100644 index 00000000..fbe58c86 --- /dev/null +++ b/services/modules/smf/views.py @@ -0,0 +1,111 @@ +from __future__ import unicode_literals + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect + +from authentication.decorators import members_and_blues +from eveonline.managers import EveManager +from services.forms import ServicePasswordForm + +from .manager import SmfManager +from .tasks import SmfTasks +from .models import SmfUser + +import logging + +logger = logging.getLogger(__name__) + + +@login_required +@members_and_blues() +def activate_smf(request): + logger.debug("activate_smf called by user %s" % request.user) + # Valid now we get the main characters + character = EveManager.get_main_character(request.user) + logger.debug("Adding smf user for user %s with main character %s" % (request.user, character)) + result = SmfManager.add_user(character.character_name, request.user.email, ['Member'], character.character_id) + # if empty we failed + if result[0] != "": + SmfUser.objects.update_or_create(user=request.user, defaults={'username': result[0]}) + logger.debug("Updated authserviceinfo for user %s with smf credentials. Updating groups." % request.user) + SmfTasks.update_groups.delay(request.user.pk) + logger.info("Successfully activated smf for user %s" % request.user) + messages.success(request, 'Activated SMF account.') + credentials = { + 'username': result[0], + 'password': result[1], + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'SMF'}) + else: + logger.error("Unsuccessful attempt to activate smf for user %s" % request.user) + messages.error(request, 'An error occurred while processing your SMF account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def deactivate_smf(request): + logger.debug("deactivate_smf called by user %s" % request.user) + result = SmfTasks.delete_user(request.user) + # false we failed + if result: + logger.info("Successfully deactivated smf for user %s" % request.user) + messages.success(request, 'Deactivated SMF account.') + else: + logger.error("Unsuccessful attempt to activate smf for user %s" % request.user) + messages.error(request, 'An error occurred while processing your SMF account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def reset_smf_password(request): + logger.debug("reset_smf_password called by user %s" % request.user) + character = EveManager.get_main_character(request.user) + if SmfTasks.has_account(request.user) and character is not None: + result = SmfManager.update_user_password(request.user.smf.username, character.character_id) + # false we failed + if result != "": + logger.info("Successfully reset smf password for user %s" % request.user) + messages.success(request, 'Reset SMF password.') + credentials = { + 'username': request.user.smf.username, + 'password': result, + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'SMF'}) + logger.error("Unsuccessful attempt to reset smf password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your SMF account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def set_smf_password(request): + logger.debug("set_smf_password called by user %s" % request.user) + if request.method == 'POST': + logger.debug("Received POST request with form.") + form = ServicePasswordForm(request.POST) + logger.debug("Form is valid: %s" % form.is_valid()) + character = EveManager.get_main_character(request.user) + if form.is_valid() and SmfTasks.has_account(request.user) and character is not None: + password = form.cleaned_data['password'] + logger.debug("Form contains password of length %s" % len(password)) + result = SmfManager.update_user_password(request.user.smf.username, character.character_id, + password=password) + if result != "": + logger.info("Successfully set smf password for user %s" % request.user) + messages.success(request, 'Set SMF password.') + else: + logger.error("Failed to install custom smf password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your SMF account.') + return redirect("auth_services") + else: + logger.debug("Request is not type POST - providing empty form.") + form = ServicePasswordForm() + + logger.debug("Rendering form for user %s" % request.user) + context = {'form': form, 'service': 'SMF'} + return render(request, 'registered/service_password.html', context=context) diff --git a/services/modules/teamspeak3/__init__.py b/services/modules/teamspeak3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/teamspeak3/admin.py b/services/modules/teamspeak3/admin.py new file mode 100644 index 00000000..2ff77e24 --- /dev/null +++ b/services/modules/teamspeak3/admin.py @@ -0,0 +1,17 @@ +from __future__ import unicode_literals +from django.contrib import admin +from .models import AuthTS, Teamspeak3User + + +class Teamspeak3UserAdmin(admin.ModelAdmin): + list_display = ('user', 'uid', 'perm_key') + search_fields = ('user__username', 'uid', 'perm_key') + + +class AuthTSgroupAdmin(admin.ModelAdmin): + fields = ['auth_group', 'ts_group'] + filter_horizontal = ('ts_group',) + + +admin.site.register(AuthTS, AuthTSgroupAdmin) +admin.site.register(Teamspeak3User, Teamspeak3UserAdmin) diff --git a/services/modules/teamspeak3/apps.py b/services/modules/teamspeak3/apps.py new file mode 100644 index 00000000..1d542597 --- /dev/null +++ b/services/modules/teamspeak3/apps.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class Teamspeak3ServiceConfig(AppConfig): + name = 'teamspeak3' + + def ready(self): + from . import signals diff --git a/services/modules/teamspeak3/auth_hooks.py b/services/modules/teamspeak3/auth_hooks.py new file mode 100644 index 00000000..b740b90f --- /dev/null +++ b/services/modules/teamspeak3/auth_hooks.py @@ -0,0 +1,61 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.template.loader import render_to_string + +from services.hooks import ServicesHook +from alliance_auth import hooks + +from .urls import urlpatterns +from .tasks import Teamspeak3Tasks + +import logging + +logger = logging.getLogger(__name__) + + +class Teamspeak3Service(ServicesHook): + def __init__(self): + ServicesHook.__init__(self) + self.name = 'teamspeak3' + self.urlpatterns = urlpatterns + self.service_ctrl_template = 'registered/teamspeak3_service_ctrl.html' + + def delete_user(self, user, notify_user=False): + logger.debug('Deleting user %s %s account' % (user, self.name)) + return Teamspeak3Tasks.delete_user(user, notify_user=notify_user) + + def update_groups(self, user): + logger.debug('Updating %s groups for %s' % (self.name, user)) + Teamspeak3Tasks.update_groups.delay(user.pk) + + def validate_user(self, user): + logger.debug('Validating user %s %s account' % (user, self.name)) + if Teamspeak3Tasks.has_account(user) and not self.service_active_for_user(user): + self.delete_user(user, notify_user=True) + + def update_all_groups(self): + logger.debug('Update all %s groups called' % self.name) + Teamspeak3Tasks.update_all_groups.delay() + + def service_enabled_members(self): + return settings.ENABLE_AUTH_TEAMSPEAK3 or False + + def service_enabled_blues(self): + return settings.ENABLE_BLUE_TEAMSPEAK3 or False + + def render_services_ctrl(self, request): + authinfo = {'teamspeak3_uid': '', + 'teamspeak3_perm_key': '',} + if Teamspeak3Tasks.has_account(request.user): + authinfo['teamspeak3_uid'] = request.user.teamspeak3.uid + authinfo['teamspeak3_perm_key'] = request.user.teamspeak3.perm_key + + return render_to_string(self.service_ctrl_template, { + 'authinfo': authinfo, + }, request=request) + + +@hooks.register('services_hook') +def register_service(): + return Teamspeak3Service() diff --git a/services/modules/teamspeak3/forms.py b/services/modules/teamspeak3/forms.py new file mode 100644 index 00000000..4377ebcc --- /dev/null +++ b/services/modules/teamspeak3/forms.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals + +from django import forms +from django.utils.translation import ugettext_lazy as _ + +from .manager import Teamspeak3Manager + + +class TeamspeakJoinForm(forms.Form): + username = forms.CharField(widget=forms.HiddenInput()) + + def clean(self): + if Teamspeak3Manager._get_userid(self.cleaned_data['username']): + return self.cleaned_data + raise forms.ValidationError(_("Unable to locate user %s on server") % self.cleaned_data['username']) diff --git a/services/managers/teamspeak3_manager.py b/services/modules/teamspeak3/manager.py similarity index 99% rename from services/managers/teamspeak3_manager.py rename to services/modules/teamspeak3/manager.py index a6dc8016..5661b62f 100755 --- a/services/managers/teamspeak3_manager.py +++ b/services/modules/teamspeak3/manager.py @@ -1,9 +1,11 @@ from __future__ import unicode_literals + +import logging + from django.conf import settings -from services.managers.util.ts3 import TS3Server, TeamspeakError -from services.models import TSgroup -import logging +from services.modules.teamspeak3.util.ts3 import TS3Server, TeamspeakError +from .models import TSgroup logger = logging.getLogger(__name__) diff --git a/services/modules/teamspeak3/migrations/0001_initial.py b/services/modules/teamspeak3/migrations/0001_initial.py new file mode 100644 index 00000000..0e5f552f --- /dev/null +++ b/services/modules/teamspeak3/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 01:11 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AuthTS', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('auth_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.Group')), + ], + options={ + 'db_table': 'services_authts', + 'verbose_name': 'Auth / TS Group', + }, + ), + migrations.CreateModel( + name='TSgroup', + fields=[ + ('ts_group_id', models.IntegerField(primary_key=True, serialize=False)), + ('ts_group_name', models.CharField(max_length=30)), + ], + options={ + 'db_table': 'services_tsgroup', + 'verbose_name': 'TS Group', + }, + ), + migrations.CreateModel( + name='UserTSgroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ts_group', models.ManyToManyField(to='teamspeak3.TSgroup')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'services_usertsgroup', + 'verbose_name': 'User TS Group', + }, + ), + migrations.AddField( + model_name='authts', + name='ts_group', + field=models.ManyToManyField(to='teamspeak3.TSgroup'), + ), + ] diff --git a/services/modules/teamspeak3/migrations/0002_auto_20161212_0133.py b/services/modules/teamspeak3/migrations/0002_auto_20161212_0133.py new file mode 100644 index 00000000..317de302 --- /dev/null +++ b/services/modules/teamspeak3/migrations/0002_auto_20161212_0133.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 01:33 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('teamspeak3', '0001_initial'), + ] + + operations = [ + migrations.AlterModelTable( + name='authts', + table=None, + ), + migrations.AlterModelTable( + name='tsgroup', + table=None, + ), + migrations.AlterModelTable( + name='usertsgroup', + table=None, + ), + ] diff --git a/services/modules/teamspeak3/migrations/0003_teamspeak3user.py b/services/modules/teamspeak3/migrations/0003_teamspeak3user.py new file mode 100644 index 00000000..714c6fff --- /dev/null +++ b/services/modules/teamspeak3/migrations/0003_teamspeak3user.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 03:14 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ('teamspeak3', '0002_auto_20161212_0133'), + ] + + operations = [ + migrations.CreateModel( + name='Teamspeak3User', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='teamspeak3', serialize=False, to=settings.AUTH_USER_MODEL)), + ('uid', models.CharField(max_length=254)), + ('perm_key', models.CharField(max_length=254)), + ], + ), + ] diff --git a/services/modules/teamspeak3/migrations/__init__.py b/services/modules/teamspeak3/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/teamspeak3/models.py b/services/modules/teamspeak3/models.py new file mode 100644 index 00000000..6e0aeaf3 --- /dev/null +++ b/services/modules/teamspeak3/models.py @@ -0,0 +1,52 @@ +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible +from django.db import models + + +@python_2_unicode_compatible +class Teamspeak3User(models.Model): + user = models.OneToOneField('auth.User', + primary_key=True, + on_delete=models.CASCADE, + related_name='teamspeak3') + uid = models.CharField(max_length=254) + perm_key = models.CharField(max_length=254) + + def __str__(self): + return self.uid + + +@python_2_unicode_compatible +class TSgroup(models.Model): + ts_group_id = models.IntegerField(primary_key=True) + ts_group_name = models.CharField(max_length=30) + + class Meta: + verbose_name = 'TS Group' + + def __str__(self): + return self.ts_group_name + + +@python_2_unicode_compatible +class AuthTS(models.Model): + auth_group = models.ForeignKey('auth.Group') + ts_group = models.ManyToManyField(TSgroup) + + class Meta: + verbose_name = 'Auth / TS Group' + + def __str__(self): + return self.auth_group.name + + +@python_2_unicode_compatible +class UserTSgroup(models.Model): + user = models.ForeignKey('auth.User') + ts_group = models.ManyToManyField(TSgroup) + + class Meta: + verbose_name = 'User TS Group' + + def __str__(self): + return self.user.name diff --git a/services/modules/teamspeak3/signals.py b/services/modules/teamspeak3/signals.py new file mode 100644 index 00000000..ad8b4185 --- /dev/null +++ b/services/modules/teamspeak3/signals.py @@ -0,0 +1,38 @@ +from __future__ import unicode_literals + +import logging + +from django.db import transaction +from django.db.models.signals import m2m_changed +from django.db.models.signals import post_delete +from django.db.models.signals import post_save +from django.dispatch import receiver + +from .tasks import Teamspeak3Tasks +from .models import AuthTS + +logger = logging.getLogger(__name__) + + +def trigger_all_ts_update(): + logger.debug("Triggering update_all_groups") + Teamspeak3Tasks.update_all_groups() + + +@receiver(m2m_changed, sender=AuthTS.ts_group.through) +def m2m_changed_authts_group(sender, instance, action, *args, **kwargs): + logger.debug("Received m2m_changed from %s ts_group with action %s" % (instance, action)) + if action == "post_add" or action == "post_remove": + transaction.on_commit(trigger_all_ts_update) + + +@receiver(post_save, sender=AuthTS) +def post_save_authts(sender, instance, *args, **kwargs): + logger.debug("Received post_save from %s" % instance) + transaction.on_commit(trigger_all_ts_update) + + +@receiver(post_delete, sender=AuthTS) +def post_delete_authts(sender, instance, *args, **kwargs): + logger.debug("Received post_delete signal from %s" % instance) + transaction.on_commit(trigger_all_ts_update) diff --git a/services/modules/teamspeak3/tasks.py b/services/modules/teamspeak3/tasks.py new file mode 100644 index 00000000..be8839c9 --- /dev/null +++ b/services/modules/teamspeak3/tasks.py @@ -0,0 +1,96 @@ +from __future__ import unicode_literals + +import logging + +from alliance_auth.celeryapp import app +from celery.schedules import crontab +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist +from notifications import notify + +from authentication.models import AuthServicesInfo +from .util.ts3 import TeamspeakError +from .manager import Teamspeak3Manager +from .models import AuthTS, TSgroup, UserTSgroup, Teamspeak3User + +logger = logging.getLogger(__name__) + + +class Teamspeak3Tasks: + def __init__(self): + pass + + @classmethod + def delete_user(cls, user, notify_user=False): + if cls.has_account(user): + logger.debug("User %s has TS3 account %s. Deleting." % (user, user.teamspeak3.uid)) + if Teamspeak3Manager.delete_user(user.teamspeak3.uid): + user.teamspeak3.delete() + if notify_user: + notify(user, 'TeamSpeak3 Account Disabled', level='danger') + return True + return False + + @staticmethod + def has_account(user): + try: + return user.teamspeak3.uid != '' + except ObjectDoesNotExist: + return False + + @staticmethod + @app.task() + def run_ts3_group_update(): + if settings.ENABLE_AUTH_TEAMSPEAK3 or settings.ENABLE_BLUE_TEAMSPEAK3: + logger.debug("TS3 installed. Syncing local group objects.") + Teamspeak3Manager._sync_ts_group_db() + + @staticmethod + def disable(): + if settings.ENABLE_AUTH_TEAMSPEAK3: + logger.warn( + "ENABLE_AUTH_TEAMSPEAK3 still True, after disabling users will still be able to create teamspeak accounts") + if settings.ENABLE_BLUE_TEAMSPEAK3: + logger.warn( + "ENABLE_BLUE_TEAMSPEAK3 still True, after disabling blues will still be able to create teamspeak accounts") + logger.info("Deleting all Teamspeak3Users") + Teamspeak3User.objects.all().delete() + logger.info("Deleting all UserTSgroup models") + UserTSgroup.objects.all().delete() + logger.info("Deleting all AuthTS models") + AuthTS.objects.all().delete() + logger.info("Deleting all TSgroup models") + TSgroup.objects.all().delete() + logger.info("Teamspeak3 disabled") + + @staticmethod + @app.task(bind=True) + def update_groups(self, pk): + user = User.objects.get(pk=pk) + logger.debug("Updating user %s teamspeak3 groups" % user) + if Teamspeak3Tasks.has_account(user): + usergroups = user.groups.all() + groups = {} + for usergroup in usergroups: + filtered_groups = AuthTS.objects.filter(auth_group=usergroup) + if filtered_groups: + for filtered_group in filtered_groups: + for ts_group in filtered_group.ts_group.all(): + groups[ts_group.ts_group_name] = ts_group.ts_group_id + logger.debug("Updating user %s teamspeak3 groups to %s" % (user, groups)) + try: + Teamspeak3Manager.update_groups(user.teamspeak3.uid, groups) + logger.debug("Updated user %s teamspeak3 groups." % user) + except TeamspeakError as e: + logger.error("Error occured while syncing TS groups for %s: %s" % (user, str(e))) + raise self.retry(countdown=60*10) + else: + logger.debug("User does not have a teamspeak3 account") + + @staticmethod + @app.task + def update_all_groups(): + logger.debug("Updating ALL teamspeak3 groups") + for user in Teamspeak3User.objects.exclude(uid__exact=''): + Teamspeak3Tasks.update_groups.delay(user.user_id) diff --git a/services/modules/teamspeak3/templates/registered/teamspeak3_service_ctrl.html b/services/modules/teamspeak3/templates/registered/teamspeak3_service_ctrl.html new file mode 100644 index 00000000..7ac4840b --- /dev/null +++ b/services/modules/teamspeak3/templates/registered/teamspeak3_service_ctrl.html @@ -0,0 +1,34 @@ +{% load i18n %} + + + {% trans "Service" %} + {% trans "Unique ID" %} + PermissionKey + {% trans "Action" %} + + + Teamspeak 3 + {{ authinfo.teamspeak3_uid }} + {{ authinfo.teamspeak3_perm_key }} + + {% ifequal authinfo.teamspeak3_uid "" %} + + + + {% else %} + + + + + + + + + + + + + {% endifequal %} + + \ No newline at end of file diff --git a/services/modules/teamspeak3/tests.py b/services/modules/teamspeak3/tests.py new file mode 100644 index 00000000..ba8e166c --- /dev/null +++ b/services/modules/teamspeak3/tests.py @@ -0,0 +1,328 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase, RequestFactory +from django.conf import settings +from django import urls +from django.contrib.auth.models import User, Group +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import signals + +from alliance_auth.tests.auth_utils import AuthUtils + +from .auth_hooks import Teamspeak3Service +from .models import Teamspeak3User, AuthTS, TSgroup +from .tasks import Teamspeak3Tasks +from .signals import m2m_changed_authts_group, post_save_authts, post_delete_authts + +MODULE_PATH = 'services.modules.teamspeak3' + + +class Teamspeak3HooksTestCase(TestCase): + def setUp(self): + # Inert signals before setup begins + with mock.patch(MODULE_PATH + '.signals.trigger_all_ts_update') as trigger_all_ts_update: + self.member = 'member_user' + member = AuthUtils.create_member(self.member) + Teamspeak3User.objects.create(user=member, uid=self.member, perm_key='123ABC') + self.blue = 'blue_user' + blue = AuthUtils.create_blue(self.blue) + Teamspeak3User.objects.create(user=blue, uid=self.blue, perm_key='456DEF') + self.none_user = 'none_user' + none_user = AuthUtils.create_user(self.none_user) + + ts_member_group = TSgroup.objects.create(ts_group_id=1, ts_group_name='Member') + ts_blue_group = TSgroup.objects.create(ts_group_id=2, ts_group_name='Blue') + m2m_member_group = AuthTS.objects.create(auth_group=member.groups.all()[0]) + m2m_member_group.ts_group.add(ts_member_group) + m2m_member_group.save() + m2m_blue_group = AuthTS.objects.create(auth_group=blue.groups.all()[0]) + m2m_blue_group.ts_group.add(ts_blue_group) + m2m_blue_group.save() + self.service = Teamspeak3Service + + def test_has_account(self): + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(Teamspeak3Tasks.has_account(member)) + self.assertTrue(Teamspeak3Tasks.has_account(blue)) + self.assertFalse(Teamspeak3Tasks.has_account(none_user)) + + def test_service_enabled(self): + service = self.service() + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(service.service_enabled_members()) + self.assertTrue(service.service_enabled_blues()) + + self.assertEqual(service.service_active_for_user(member), settings.ENABLE_AUTH_TEAMSPEAK3) + self.assertEqual(service.service_active_for_user(blue), settings.ENABLE_BLUE_TEAMSPEAK3) + self.assertFalse(service.service_active_for_user(none_user)) + + @mock.patch(MODULE_PATH + '.tasks.Teamspeak3Manager') + def test_update_all_groups(self, manager): + service = self.service() + service.update_all_groups() + # Check member and blue user have groups updated + self.assertTrue(manager.update_groups.called) + self.assertEqual(manager.update_groups.call_count, 2) + + def test_update_groups(self): + # Check member has Member group updated + with mock.patch(MODULE_PATH + '.tasks.Teamspeak3Manager') as manager: + service = self.service() + member = User.objects.get(username=self.member) + service.update_groups(member) + self.assertTrue(manager.update_groups.called) + args, kwargs = manager.update_groups.call_args + # update_groups(user.teamspeak3.uid, groups) + self.assertEqual({'Member': 1}, args[1]) # Check groups + self.assertEqual(self.member, args[0]) # Check uid + + # Check none user does not have groups updated + with mock.patch(MODULE_PATH + '.tasks.Teamspeak3Manager') as manager: + service = self.service() + none_user = User.objects.get(username=self.none_user) + service.update_groups(none_user) + self.assertFalse(manager.update_user_groups.called) + + @mock.patch(MODULE_PATH + '.tasks.Teamspeak3Manager') + def test_validate_user(self, manager): + service = self.service() + # Test member is not deleted + member = User.objects.get(username=self.member) + service.validate_user(member) + self.assertTrue(member.teamspeak3) + + # Test none user is deleted + none_user = User.objects.get(username=self.none_user) + Teamspeak3User.objects.create(user=none_user, uid='abc123', perm_key='132ACB') + service.validate_user(none_user) + self.assertTrue(manager.delete_user.called) + with self.assertRaises(ObjectDoesNotExist): + none_teamspeak3 = User.objects.get(username=self.none_user).teamspeak3 + + @mock.patch(MODULE_PATH + '.tasks.Teamspeak3Manager') + def test_delete_user(self, manager): + member = User.objects.get(username=self.member) + + service = self.service() + result = service.delete_user(member) + + self.assertTrue(result) + self.assertTrue(manager.delete_user.called) + with self.assertRaises(ObjectDoesNotExist): + teamspeak3_user = User.objects.get(username=self.member).teamspeak3 + + def test_render_services_ctrl(self): + service = self.service() + member = User.objects.get(username=self.member) + request = RequestFactory().get('/en/services/') + request.user = member + + response = service.render_services_ctrl(request) + self.assertTemplateUsed(service.service_ctrl_template) + self.assertIn(urls.reverse('auth_deactivate_teamspeak3'), response) + self.assertIn(urls.reverse('auth_reset_teamspeak3_perm'), response) + + # Test register becomes available + member.teamspeak3.delete() + member = User.objects.get(username=self.member) + request.user = member + response = service.render_services_ctrl(request) + self.assertIn(urls.reverse('auth_activate_teamspeak3'), response) + + +class Teamspeak3ViewsTestCase(TestCase): + def setUp(self): + # Inert signals before setup begins + with mock.patch(MODULE_PATH + '.signals.trigger_all_ts_update') as trigger_all_ts_update: + self.member = AuthUtils.create_member('auth_member') + self.member.set_password('password') + self.member.email = 'auth_member@example.com' + self.member.save() + AuthUtils.add_main_character(self.member, 'auth_member', '12345', corp_id='111', corp_name='Test Corporation') + self.blue_user = AuthUtils.create_blue('auth_blue') + self.blue_user.set_password('password') + self.blue_user.email = 'auth_blue@example.com' + self.blue_user.save() + AuthUtils.add_main_character(self.blue_user, 'auth_blue', '92345', corp_id='111', corp_name='Test Corporation') + + ts_member_group = TSgroup.objects.create(ts_group_id=1, ts_group_name='Member') + ts_blue_group = TSgroup.objects.create(ts_group_id=2, ts_group_name='Blue') + m2m_member = AuthTS.objects.create(auth_group=Group.objects.get(name='Member')) + m2m_member.ts_group.add(ts_member_group) + m2m_member.save() + m2m_blue = AuthTS.objects.create(auth_group=Group.objects.get(name='Blue')) + m2m_blue.ts_group.add(ts_blue_group) + m2m_blue.save() + + def login(self, user=None, password=None): + if user is None: + user = self.member + self.client.login(username=user.username, password=password if password else 'password') + + @mock.patch(MODULE_PATH + '.forms.Teamspeak3Manager') + @mock.patch(MODULE_PATH + '.views.Teamspeak3Manager') + def test_activate(self, manager, forms_manager): + self.login() + expected_username = 'auth_member' + manager.add_user.return_value = (expected_username, 'abc123') + + response = self.client.get(urls.reverse('auth_activate_teamspeak3')) + + self.assertTrue(manager.add_user.called) + teamspeak3_user = Teamspeak3User.objects.get(user=self.member) + self.assertTrue(teamspeak3_user.uid) + self.assertTrue(teamspeak3_user.perm_key) + self.assertRedirects(response, urls.reverse('auth_verify_teamspeak3'), target_status_code=200) + + @mock.patch(MODULE_PATH + '.forms.Teamspeak3Manager') + @mock.patch(MODULE_PATH + '.views.Teamspeak3Manager') + def test_activate_blue(self, manager, forms_manager): + self.login(self.blue_user) + expected_username = 'auth_blue' + manager.add_blue_user.return_value = (expected_username, 'abc123') + + response = self.client.get(urls.reverse('auth_activate_teamspeak3')) + + self.assertTrue(manager.add_blue_user.called) + teamspeak3_user = Teamspeak3User.objects.get(user=self.blue_user) + self.assertTrue(teamspeak3_user.uid) + self.assertTrue(teamspeak3_user.perm_key) + self.assertRedirects(response, urls.reverse('auth_verify_teamspeak3'), target_status_code=200) + + @mock.patch(MODULE_PATH + '.forms.Teamspeak3Manager') + @mock.patch(MODULE_PATH + '.tasks.Teamspeak3Manager') + def test_verify_submit(self, manager, forms_manager): + self.login() + expected_username = 'auth_member' + + forms_manager._get_userid.return_value = '1234' + + Teamspeak3User.objects.update_or_create(user=self.member, defaults={'uid': '1234', 'perm_key': '5678'}) + data = {'username': 'auth_member'} + + response = self.client.post(urls.reverse('auth_verify_teamspeak3'), data) + + self.assertTrue(manager.update_groups.called) + self.assertRedirects(response, urls.reverse('auth_services'), target_status_code=200) + + @mock.patch(MODULE_PATH + '.tasks.Teamspeak3Manager') + def test_deactivate(self, manager): + self.login() + Teamspeak3User.objects.create(user=self.member, uid='some member') + + response = self.client.get(urls.reverse('auth_deactivate_teamspeak3')) + + self.assertTrue(manager.delete_user.called) + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + with self.assertRaises(ObjectDoesNotExist): + teamspeak3_user = User.objects.get(pk=self.member.pk).teamspeak3 + + @mock.patch(MODULE_PATH + '.tasks.Teamspeak3Manager') + @mock.patch(MODULE_PATH + '.views.Teamspeak3Manager') + def test_reset_perm(self, manager, tasks_manager): + self.login() + Teamspeak3User.objects.create(user=self.member, uid='some member') + + manager.generate_new_permissionkey.return_value = "valid_member", "123abc" + + response = self.client.get(urls.reverse('auth_reset_teamspeak3_perm')) + + self.assertRedirects(response, urls.reverse('auth_services'), target_status_code=200) + ts3_user = Teamspeak3User.objects.get(uid='valid_member') + self.assertEqual(ts3_user.uid, 'valid_member') + self.assertEqual(ts3_user.perm_key, '123abc') + self.assertTrue(tasks_manager.update_groups.called) + + @mock.patch(MODULE_PATH + '.tasks.Teamspeak3Manager') + @mock.patch(MODULE_PATH + '.views.Teamspeak3Manager') + def test_reset_perm_blue(self, manager, tasks_manager): + self.login(self.blue_user) + Teamspeak3User.objects.create(user=self.blue_user, uid='some member') + + manager.generate_new_blue_permissionkey.return_value = "valid_blue", "123abc" + + response = self.client.get(urls.reverse('auth_reset_teamspeak3_perm')) + + self.assertRedirects(response, urls.reverse('auth_services'), target_status_code=200) + ts3_user = Teamspeak3User.objects.get(uid='valid_blue') + self.assertEqual(ts3_user.uid, 'valid_blue') + self.assertEqual(ts3_user.perm_key, '123abc') + self.assertTrue(tasks_manager.update_groups.called) + + +class Teamspeak3SignalsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + + # Suppress signals action while setting up + with mock.patch(MODULE_PATH + '.signals.trigger_all_ts_update') as trigger_all_ts_update: + ts_member_group = TSgroup.objects.create(ts_group_id=1, ts_group_name='Member') + self.m2m_member = AuthTS.objects.create(auth_group=Group.objects.get(name='Member')) + self.m2m_member.ts_group.add(ts_member_group) + self.m2m_member.save() + + def test_m2m_signal_registry(self): + """ + Test that the m2m signal has been registered + """ + registered_functions = [r[1]() for r in signals.m2m_changed.receivers] + self.assertIn(m2m_changed_authts_group, registered_functions) + + def test_post_save_signal_registry(self): + """ + Test that the post_save signal has been registered + """ + registered_functions = [r[1]() for r in signals.post_save.receivers] + self.assertIn(post_save_authts, registered_functions) + + def test_post_delete_signal_registry(self): + """ + Test that the post_delete signal has been registered + """ + registered_functions = [r[1]() for r in signals.post_delete.receivers] + self.assertIn(post_delete_authts, registered_functions) + + @mock.patch(MODULE_PATH + '.signals.transaction') + @mock.patch(MODULE_PATH + '.signals.trigger_all_ts_update') + def test_m2m_changed_authts_group(self, trigger_all_ts_update, transaction): + + # Overload transaction.on_commit so everything happens synchronously + transaction.on_commit = lambda fn: fn() + + new_group = TSgroup.objects.create(ts_group_id=99, ts_group_name='new TS group') + self.m2m_member.ts_group.add(new_group) + self.m2m_member.save() # Triggers signal + + self.assertTrue(trigger_all_ts_update.called) + + @mock.patch(MODULE_PATH + '.signals.transaction') + @mock.patch(MODULE_PATH + '.signals.trigger_all_ts_update') + def test_post_save_authts(self, trigger_all_ts_update, transaction): + + # Overload transaction.on_commit so everything happens synchronously + transaction.on_commit = lambda fn: fn() + + AuthTS.objects.create(auth_group=Group.objects.create(name='Test Group')) # Trigger signal (AuthTS creation) + + self.assertTrue(trigger_all_ts_update.called) + + @mock.patch(MODULE_PATH + '.signals.transaction') + @mock.patch(MODULE_PATH + '.signals.trigger_all_ts_update') + def test_post_delete_authts(self, trigger_all_ts_update, transaction): + # Overload transaction.on_commit so everything happens synchronously + transaction.on_commit = lambda fn: fn() + + self.m2m_member.delete() # Trigger delete signal + + self.assertTrue(trigger_all_ts_update.called) diff --git a/services/modules/teamspeak3/urls.py b/services/modules/teamspeak3/urls.py new file mode 100644 index 00000000..c262bdab --- /dev/null +++ b/services/modules/teamspeak3/urls.py @@ -0,0 +1,21 @@ +from __future__ import unicode_literals +from django.conf.urls import url, include + +from . import views + +module_urls = [ + # Teamspeak3 service control + url(r'^activate/$', views.activate_teamspeak3, + name='auth_activate_teamspeak3'), + url(r'^deactivate/$', views.deactivate_teamspeak3, + name='auth_deactivate_teamspeak3'), + url(r'reset_perm/$', views.reset_teamspeak3_perm, + name='auth_reset_teamspeak3_perm'), + + # Teamspeak Urls + url(r'verify/$', views.verify_teamspeak3, name='auth_verify_teamspeak3'), +] + +urlpatterns = [ + url(r'^teamspeak3/', include(module_urls)), +] diff --git a/services/managers/util/__init__.py b/services/modules/teamspeak3/util/__init__.py similarity index 100% rename from services/managers/util/__init__.py rename to services/modules/teamspeak3/util/__init__.py diff --git a/services/managers/util/ts3.py b/services/modules/teamspeak3/util/ts3.py similarity index 100% rename from services/managers/util/ts3.py rename to services/modules/teamspeak3/util/ts3.py diff --git a/services/modules/teamspeak3/views.py b/services/modules/teamspeak3/views.py new file mode 100644 index 00000000..b13e48cf --- /dev/null +++ b/services/modules/teamspeak3/views.py @@ -0,0 +1,121 @@ +import logging + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect + +from authentication.decorators import members_and_blues +from authentication.states import BLUE_STATE +from authentication.models import AuthServicesInfo +from eveonline.managers import EveManager +from eveonline.models import EveAllianceInfo + +from services.modules.teamspeak3.manager import Teamspeak3Manager + +from .forms import TeamspeakJoinForm +from .tasks import Teamspeak3Tasks +from .models import Teamspeak3User + +logger = logging.getLogger(__name__) + + +@login_required +@members_and_blues() +def activate_teamspeak3(request): + logger.debug("activate_teamspeak3 called by user %s" % request.user) + + authinfo = AuthServicesInfo.objects.get(user=request.user) + character = EveManager.get_main_character(request.user) + ticker = character.corporation_ticker + + if authinfo.state == BLUE_STATE: + logger.debug("Adding TS3 user for blue user %s with main character %s" % (request.user, character)) + # Blue members should have alliance ticker (if in alliance) + if EveAllianceInfo.objects.filter(alliance_id=character.alliance_id).exists(): + alliance = EveAllianceInfo.objects.filter(alliance_id=character.alliance_id)[0] + ticker = alliance.alliance_ticker + result = Teamspeak3Manager.add_blue_user(character.character_name, ticker) + else: + logger.debug("Adding TS3 user for user %s with main character %s" % (request.user, character)) + result = Teamspeak3Manager.add_user(character.character_name, ticker) + + # if its empty we failed + if result[0] is not "": + Teamspeak3User.objects.update_or_create(user=request.user, defaults={'uid': result[0], 'perm_key': result[1]}) + logger.debug("Updated authserviceinfo for user %s with TS3 credentials. Updating groups." % request.user) + logger.info("Successfully activated TS3 for user %s" % request.user) + messages.success(request, 'Activated TeamSpeak3 account.') + return redirect("auth_verify_teamspeak3") + logger.error("Unsuccessful attempt to activate TS3 for user %s" % request.user) + messages.error(request, 'An error occurred while processing your TeamSpeak3 account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def verify_teamspeak3(request): + logger.debug("verify_teamspeak3 called by user %s" % request.user) + if not Teamspeak3Tasks.has_account(request.user): + logger.warn("Unable to validate user %s teamspeak: no teamspeak data" % request.user) + return redirect("auth_services") + if request.method == "POST": + form = TeamspeakJoinForm(request.POST) + if form.is_valid(): + Teamspeak3Tasks.update_groups.delay(request.user.pk) + logger.debug("Validated user %s joined TS server" % request.user) + return redirect("auth_services") + else: + form = TeamspeakJoinForm({'username': request.user.teamspeak3.uid}) + context = { + 'form': form, + 'authinfo': {'teamspeak3_uid': request.user.teamspeak3.uid, + 'teamspeak3_perm_key': request.user.teamspeak3.perm_key}, + } + return render(request, 'registered/teamspeakjoin.html', context=context) + + +@login_required +@members_and_blues() +def deactivate_teamspeak3(request): + logger.debug("deactivate_teamspeak3 called by user %s" % request.user) + if Teamspeak3Tasks.has_account(request.user) and Teamspeak3Tasks.delete_user(request.user): + logger.info("Successfully deactivated TS3 for user %s" % request.user) + messages.success(request, 'Deactivated TeamSpeak3 account.') + logger.error("Unsuccessful attempt to deactivate TS3 for user %s" % request.user) + messages.error(request, 'An error occurred while processing your TeamSpeak3 account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def reset_teamspeak3_perm(request): + logger.debug("reset_teamspeak3_perm called by user %s" % request.user) + if not Teamspeak3Tasks.has_account(request.user): + return redirect("auth_services") + authinfo = AuthServicesInfo.objects.get(user=request.user) + character = EveManager.get_main_character(request.user) + logger.debug("Deleting TS3 user for user %s" % request.user) + Teamspeak3Manager.delete_user(request.user.teamspeak3.uid) + + if authinfo.state == BLUE_STATE: + logger.debug( + "Generating new permission key for blue user %s with main character %s" % (request.user, character)) + result = Teamspeak3Manager.generate_new_blue_permissionkey(request.user.teamspeak3.uid, + character.character_name, + character.corporation_ticker) + else: + logger.debug("Generating new permission key for user %s with main character %s" % (request.user, character)) + result = Teamspeak3Manager.generate_new_permissionkey(request.user.teamspeak3.uid, character.character_name, + character.corporation_ticker) + + # if blank we failed + if result[0] != "": + Teamspeak3User.objects.update_or_create(user=request.user, defaults={'uid': result[0], 'perm_key': result[1]}) + logger.debug("Updated authserviceinfo for user %s with TS3 credentials. Updating groups." % request.user) + Teamspeak3Tasks.update_groups.delay(request.user.pk) + logger.info("Successfully reset TS3 permission key for user %s" % request.user) + messages.success(request, 'Reset TeamSpeak3 permission key.') + else: + logger.error("Unsuccessful attempt to reset TS3 permission key for user %s" % request.user) + messages.error(request, 'An error occurred while processing your TeamSpeak3 account.') + return redirect("auth_services") diff --git a/services/modules/xenforo/__init__.py b/services/modules/xenforo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/xenforo/admin.py b/services/modules/xenforo/admin.py new file mode 100644 index 00000000..e614fb9c --- /dev/null +++ b/services/modules/xenforo/admin.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from django.contrib import admin +from .models import XenforoUser + + +class XenforoUserAdmin(admin.ModelAdmin): + list_display = ('user', 'username') + search_fields = ('user__username', 'username') + +admin.site.register(XenforoUser, XenforoUserAdmin) diff --git a/services/modules/xenforo/apps.py b/services/modules/xenforo/apps.py new file mode 100644 index 00000000..cc5d2400 --- /dev/null +++ b/services/modules/xenforo/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class XenforoServiceConfig(AppConfig): + name = 'xenforo' diff --git a/services/modules/xenforo/auth_hooks.py b/services/modules/xenforo/auth_hooks.py new file mode 100644 index 00000000..6309d029 --- /dev/null +++ b/services/modules/xenforo/auth_hooks.py @@ -0,0 +1,58 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.template.loader import render_to_string + +from services.hooks import ServicesHook +from alliance_auth import hooks + +from .urls import urlpatterns +from .tasks import XenforoTasks + +import logging + +logger = logging.getLogger(__name__) + + +class XenforoService(ServicesHook): + def __init__(self): + ServicesHook.__init__(self) + self.name = 'xenforo' + self.urlpatterns = urlpatterns + + @property + def title(self): + return 'XenForo Forums' + + def delete_user(self, user, notify_user=False): + logger.debug('Deleting user %s %s account' % (user, self.name)) + return XenforoTasks.delete_user(user, notify_user=notify_user) + + def validate_user(self, user): + logger.debug('Validating user %s %s account' % (user, self.name)) + if XenforoTasks.has_account(user) and not self.service_active_for_user(user): + self.delete_user(user, notify_user=True) + + def service_enabled_members(self): + return settings.ENABLE_AUTH_XENFORO or False + + def service_enabled_blues(self): + return settings.ENABLE_BLUE_XENFORO or False + + def render_services_ctrl(self, request): + urls = self.Urls() + urls.auth_activate = 'auth_activate_xenforo' + urls.auth_deactivate = 'auth_deactivate_xenforo' + urls.auth_reset_password = 'auth_reset_xenforo_password' + urls.auth_set_password = 'auth_set_xenforo_password' + return render_to_string(self.service_ctrl_template, { + 'service_name': self.title, + 'urls': urls, + 'service_url': '', + 'username': request.user.xenforo.username if XenforoTasks.has_account(request.user) else '' + }, request=request) + + +@hooks.register('services_hook') +def register_service(): + return XenforoService() diff --git a/services/managers/xenforo_manager.py b/services/modules/xenforo/manager.py similarity index 96% rename from services/managers/xenforo_manager.py rename to services/modules/xenforo/manager.py index aaa4ca3f..65415b50 100644 --- a/services/managers/xenforo_manager.py +++ b/services/modules/xenforo/manager.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals -import os +import random +import string import requests import json @@ -25,7 +26,7 @@ class XenForoManager: @staticmethod def __generate_password(): - return os.urandom(8).encode('hex') + return ''.join([random.choice(string.ascii_letters + string.digits) for n in range(16)]) @staticmethod def exec_http_request(http_params): diff --git a/services/modules/xenforo/migrations/0001_initial.py b/services/modules/xenforo/migrations/0001_initial.py new file mode 100644 index 00000000..c721a776 --- /dev/null +++ b/services/modules/xenforo/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-12-12 03:14 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0008_alter_user_username_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='XenforoUser', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='xenforo', serialize=False, to=settings.AUTH_USER_MODEL)), + ('username', models.CharField(max_length=254)), + ], + ), + ] diff --git a/services/modules/xenforo/migrations/__init__.py b/services/modules/xenforo/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/modules/xenforo/models.py b/services/modules/xenforo/models.py new file mode 100644 index 00000000..de9e864c --- /dev/null +++ b/services/modules/xenforo/models.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals +from django.utils.encoding import python_2_unicode_compatible +from django.db import models + + +@python_2_unicode_compatible +class XenforoUser(models.Model): + user = models.OneToOneField('auth.User', + primary_key=True, + on_delete=models.CASCADE, + related_name='xenforo') + username = models.CharField(max_length=254) + + def __str__(self): + return self.username diff --git a/services/modules/xenforo/tasks.py b/services/modules/xenforo/tasks.py new file mode 100644 index 00000000..d3bf9642 --- /dev/null +++ b/services/modules/xenforo/tasks.py @@ -0,0 +1,46 @@ +from __future__ import unicode_literals + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from notifications import notify + +from .manager import XenForoManager +from .models import XenforoUser + +import logging + +logger = logging.getLogger(__name__) + + +class XenforoTasks: + def __init__(self): + pass + + @classmethod + def delete_user(cls, user, notify_user=False): + if cls.has_account(user): + logger.debug("User %s has a XenForo account %s. Deleting." % (user, user.xenforo.username)) + if XenForoManager.disable_user(user.xenforo.username) == 200: + user.xenforo.delete() + if notify_user: + notify(user, 'XenForo Account Disabled', level='danger') + return True + return False + + @staticmethod + def has_account(user): + try: + return user.xenforo.username != '' + except ObjectDoesNotExist: + return False + + @classmethod + def disable(cls): + if settings.ENABLE_AUTH_XENFORO: + logger.warn( + "ENABLE_AUTH_XENFORO still True, after disabling users will still be able to link XenForo accounts") + if settings.ENABLE_BLUE_XENFORO: + logger.warn( + "ENABLE_BLUE_XENFORO still True, after disabling blues will still be able to link XenForo accounts") + logger.debug("Deleting ALL XenForo users") + XenforoUser.objects.all().delete() diff --git a/services/modules/xenforo/tests.py b/services/modules/xenforo/tests.py new file mode 100644 index 00000000..7b8c65f2 --- /dev/null +++ b/services/modules/xenforo/tests.py @@ -0,0 +1,192 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase, RequestFactory +from django.conf import settings +from django import urls +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist + +from alliance_auth.tests.auth_utils import AuthUtils + +from .auth_hooks import XenforoService +from .models import XenforoUser +from .tasks import XenforoTasks + +MODULE_PATH = 'services.modules.xenforo' + + +class XenforoHooksTestCase(TestCase): + def setUp(self): + self.member = 'member_user' + member = AuthUtils.create_member(self.member) + XenforoUser.objects.create(user=member, username=self.member) + self.blue = 'blue_user' + blue = AuthUtils.create_blue(self.blue) + XenforoUser.objects.create(user=blue, username=self.blue) + self.none_user = 'none_user' + none_user = AuthUtils.create_user(self.none_user) + self.service = XenforoService + + def test_has_account(self): + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(XenforoTasks.has_account(member)) + self.assertTrue(XenforoTasks.has_account(blue)) + self.assertFalse(XenforoTasks.has_account(none_user)) + + def test_service_enabled(self): + service = self.service() + member = User.objects.get(username=self.member) + blue = User.objects.get(username=self.blue) + none_user = User.objects.get(username=self.none_user) + self.assertTrue(service.service_enabled_members()) + self.assertTrue(service.service_enabled_blues()) + + self.assertEqual(service.service_active_for_user(member), settings.ENABLE_AUTH_XENFORO) + self.assertEqual(service.service_active_for_user(blue), settings.ENABLE_BLUE_XENFORO) + self.assertFalse(service.service_active_for_user(none_user)) + + @mock.patch(MODULE_PATH + '.tasks.XenForoManager') + def test_validate_user(self, manager): + service = self.service() + # Test member is not deleted + member = User.objects.get(username=self.member) + manager.disable_user.return_value = 200 + + service.validate_user(member) + self.assertTrue(member.xenforo) + + # Test none user is deleted + none_user = User.objects.get(username=self.none_user) + XenforoUser.objects.create(user=none_user, username='abc123') + service.validate_user(none_user) + self.assertTrue(manager.disable_user.called) + with self.assertRaises(ObjectDoesNotExist): + none_xenforo = User.objects.get(username=self.none_user).xenforo + + @mock.patch(MODULE_PATH + '.tasks.XenForoManager') + def test_delete_user(self, manager): + member = User.objects.get(username=self.member) + manager.disable_user.return_value = 200 + service = self.service() + + result = service.delete_user(member) + + self.assertTrue(result) + self.assertTrue(manager.disable_user.called) + with self.assertRaises(ObjectDoesNotExist): + xenforo_user = User.objects.get(username=self.member).xenforo + + def test_render_services_ctrl(self): + service = self.service() + member = User.objects.get(username=self.member) + request = RequestFactory().get('/en/services/') + request.user = member + + response = service.render_services_ctrl(request) + self.assertTemplateUsed(service.service_ctrl_template) + self.assertIn(urls.reverse('auth_deactivate_xenforo'), response) + self.assertIn(urls.reverse('auth_reset_xenforo_password'), response) + self.assertIn(urls.reverse('auth_set_xenforo_password'), response) + + # Test register becomes available + member.xenforo.delete() + member = User.objects.get(username=self.member) + request.user = member + response = service.render_services_ctrl(request) + self.assertIn(urls.reverse('auth_activate_xenforo'), response) + + +class XenforoViewsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.member.set_password('password') + self.member.email = 'auth_member@example.com' + self.member.save() + AuthUtils.add_main_character(self.member, 'auth_member', '12345', corp_id='111', corp_name='Test Corporation') + + def login(self): + self.client.login(username=self.member.username, password='password') + + @mock.patch(MODULE_PATH + '.tasks.XenForoManager') + @mock.patch(MODULE_PATH + '.views.XenForoManager') + def test_activate(self, manager, tasks_manager): + self.login() + expected_username = 'auth_member' + manager.add_user.return_value = { + 'response': {'status_code': 200}, + 'password': 'hunter2', + 'username': expected_username, + } + + response = self.client.get(urls.reverse('auth_activate_xenforo')) + + self.assertTrue(manager.add_user.called) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed('registered/service_credentials.html') + self.assertContains(response, expected_username) + xenforo_user = XenforoUser.objects.get(user=self.member) + self.assertEqual(xenforo_user.username, expected_username) + + @mock.patch(MODULE_PATH + '.tasks.XenForoManager') + def test_deactivate(self, manager): + self.login() + XenforoUser.objects.create(user=self.member, username='some member') + + manager.disable_user.return_value = 200 + + response = self.client.get(urls.reverse('auth_deactivate_xenforo')) + + self.assertTrue(manager.disable_user.called) + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + with self.assertRaises(ObjectDoesNotExist): + xenforo_user = User.objects.get(pk=self.member.pk).xenforo + + @mock.patch(MODULE_PATH + '.views.XenForoManager') + def test_set_password(self, manager): + self.login() + XenforoUser.objects.create(user=self.member, username='some member') + + response = self.client.post(urls.reverse('auth_set_xenforo_password'), data={'password': '1234asdf'}) + + self.assertTrue(manager.update_user_password.called) + args, kwargs = manager.update_user_password.call_args + self.assertEqual(args[1], '1234asdf') + self.assertRedirects(response, expected_url=urls.reverse('auth_services'), target_status_code=200) + + @mock.patch(MODULE_PATH + '.views.XenForoManager') + def test_reset_password(self, manager): + self.login() + XenforoUser.objects.create(user=self.member, username='some member') + + manager.reset_password.return_value = { + 'response': {'status_code': 200}, + 'password': 'hunter2', + } + + response = self.client.get(urls.reverse('auth_reset_xenforo_password')) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'registered/service_credentials.html') + self.assertContains(response, 'some member') + self.assertContains(response, 'hunter2') + + +class XenforoManagerTestCase(TestCase): + def setUp(self): + from .manager import XenForoManager + self.manager = XenForoManager + + def test_generate_password(self): + password = self.manager._XenForoManager__generate_password() + + self.assertEqual(len(password), 16) + self.assertIsInstance(password, type('')) diff --git a/services/modules/xenforo/urls.py b/services/modules/xenforo/urls.py new file mode 100644 index 00000000..6993a33e --- /dev/null +++ b/services/modules/xenforo/urls.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals +from django.conf.urls import url, include + +from . import views + +module_urls = [ + # XenForo service control + url(r'^activate/$', views.activate_xenforo_forum, name='auth_activate_xenforo'), + url(r'^deactivate/$', views.deactivate_xenforo_forum, name='auth_deactivate_xenforo'), + url(r'^reset_password/$', views.reset_xenforo_password, name='auth_reset_xenforo_password'), + url(r'^set_password/$', views.set_xenforo_password, name='auth_set_xenforo_password'), +] + +urlpatterns = [ + url(r'^xenforo/', include(module_urls)), +] diff --git a/services/modules/xenforo/views.py b/services/modules/xenforo/views.py new file mode 100644 index 00000000..6fe79362 --- /dev/null +++ b/services/modules/xenforo/views.py @@ -0,0 +1,101 @@ +from __future__ import unicode_literals + +import logging + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect + +from authentication.decorators import members_and_blues +from eveonline.managers import EveManager +from services.forms import ServicePasswordForm +from .manager import XenForoManager +from .models import XenforoUser +from .tasks import XenforoTasks + +logger = logging.getLogger(__name__) + +@login_required +@members_and_blues() +def activate_xenforo_forum(request): + logger.debug("activate_xenforo_forum called by user %s" % request.user) + character = EveManager.get_main_character(request.user) + logger.debug("Adding XenForo user for user %s with main character %s" % (request.user, character)) + result = XenForoManager.add_user(character.character_name, request.user.email) + # Based on XenAPI's response codes + if result['response']['status_code'] == 200: + XenforoUser.objects.update_or_create(user=request.user, defaults={'username': result['username']}) + logger.info("Updated user %s with XenForo credentials. Updating groups." % request.user) + messages.success(request, 'Activated XenForo account.') + credentials = { + 'username': result['username'], + 'password': result['password'], + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'XenForo'}) + + else: + logger.error("Unsuccessful attempt to activate xenforo for user %s" % request.user) + messages.error(request, 'An error occurred while processing your XenForo account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def deactivate_xenforo_forum(request): + logger.debug("deactivate_xenforo_forum called by user %s" % request.user) + if XenforoTasks.delete_user(request.user): + logger.info("Successfully deactivated XenForo for user %s" % request.user) + messages.success(request, 'Deactivated XenForo account.') + else: + messages.error(request, 'An error occurred while processing your XenForo account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def reset_xenforo_password(request): + logger.debug("reset_xenforo_password called by user %s" % request.user) + if XenforoTasks.has_account(request.user): + result = XenForoManager.reset_password(request.user.xenforo.username) + # Based on XenAPI's response codes + if result['response']['status_code'] == 200: + logger.info("Successfully reset XenForo password for user %s" % request.user) + messages.success(request, 'Reset XenForo account password.') + credentials = { + 'username': request.user.xenforo.username, + 'password': result['password'], + } + return render(request, 'registered/service_credentials.html', + context={'credentials': credentials, 'service': 'XenForo'}) + logger.error("Unsuccessful attempt to reset XenForo password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your XenForo account.') + return redirect("auth_services") + + +@login_required +@members_and_blues() +def set_xenforo_password(request): + logger.debug("set_xenforo_password called by user %s" % request.user) + if request.method == 'POST': + logger.debug("Received POST request with form.") + form = ServicePasswordForm(request.POST) + logger.debug("Form is valid: %s" % form.is_valid()) + if form.is_valid() and XenforoTasks.has_account(request.user): + password = form.cleaned_data['password'] + logger.debug("Form contains password of length %s" % len(password)) + result = XenForoManager.update_user_password(request.user.xenforo.username, password) + if result['response']['status_code'] == 200: + logger.info("Successfully reset XenForo password for user %s" % request.user) + messages.success(request, 'Changed XenForo password.') + else: + logger.error("Failed to install custom XenForo password for user %s" % request.user) + messages.error(request, 'An error occurred while processing your XenForo account.') + return redirect('auth_services') + else: + logger.debug("Request is not type POST - providing empty form.") + form = ServicePasswordForm() + + logger.debug("Rendering form for user %s" % request.user) + context = {'form': form, 'service': 'Forum'} + return render(request, 'registered/service_password.html', context=context) diff --git a/services/signals.py b/services/signals.py index ebbaa874..2bd19636 100644 --- a/services/signals.py +++ b/services/signals.py @@ -1,25 +1,17 @@ from __future__ import unicode_literals + +import logging + +from django.contrib.auth.models import User from django.db import transaction from django.db.models.signals import m2m_changed -from django.db.models.signals import post_save -from django.db.models.signals import pre_save -from django.db.models.signals import post_delete from django.db.models.signals import pre_delete +from django.db.models.signals import pre_save from django.dispatch import receiver -from django.contrib.auth.models import User -import logging -from services.tasks import update_jabber_groups -from services.tasks import update_mumble_groups -from services.tasks import update_forum_groups -from services.tasks import update_ipboard_groups -from services.tasks import update_discord_groups -from services.tasks import update_teamspeak3_groups -from services.tasks import update_discourse_groups -from services.tasks import update_smf_groups -from authentication.tasks import set_state + +from alliance_auth.hooks import get_hooks from authentication.tasks import disable_member -from authentication.models import AuthServicesInfo -from services.models import AuthTS +from authentication.tasks import set_state logger = logging.getLogger(__name__) @@ -30,55 +22,20 @@ def m2m_changed_user_groups(sender, instance, action, *args, **kwargs): def trigger_service_group_update(): logger.debug("Triggering service group update for %s" % instance) - auth = AuthServicesInfo.objects.get(user=instance) - if auth.jabber_username: - update_jabber_groups.delay(instance.pk) - if auth.teamspeak3_uid: - update_teamspeak3_groups.delay(instance.pk) - if auth.forum_username: - update_forum_groups.delay(instance.pk) - if auth.smf_username: - update_smf_groups.delay(instance.pk) - if auth.ipboard_username: - update_ipboard_groups.delay(instance.pk) - if auth.discord_uid: - update_discord_groups.delay(instance.pk) - if auth.mumble_username: - update_mumble_groups.delay(instance.pk) - if auth.discourse_enabled: - update_discourse_groups.delay(instance.pk) - if auth.smf_username: - update_smf_groups.delay(instance.pk) + # Iterate through Service hooks + services = get_hooks('services_hook') + for fn in services: + svc = fn() + try: + svc.update_groups(instance) + except: + logger.exception('Exception running update_groups for services module %s on user %s' % (svc, instance)) if instance.pk and (action == "post_add" or action == "post_remove" or action == "post_clear"): logger.debug("Waiting for commit to trigger service group update for %s" % instance) transaction.on_commit(trigger_service_group_update) -def trigger_all_ts_update(): - for auth in AuthServicesInfo.objects.filter(teamspeak3_uid__isnull=False): - update_teamspeak3_groups.delay(auth.user.pk) - - -@receiver(m2m_changed, sender=AuthTS.ts_group.through) -def m2m_changed_authts_group(sender, instance, action, *args, **kwargs): - logger.debug("Received m2m_changed from %s ts_group with action %s" % (instance, action)) - if action == "post_add" or action == "post_remove": - trigger_all_ts_update() - - -@receiver(post_save, sender=AuthTS) -def post_save_authts(sender, instance, *args, **kwargs): - logger.debug("Received post_save from %s" % instance) - trigger_all_ts_update() - - -@receiver(post_delete, sender=AuthTS) -def post_delete_authts(sender, instance, *args, **kwargs): - logger.debug("Received post_delete signal from %s" % instance) - trigger_all_ts_update() - - @receiver(pre_delete, sender=User) def pre_delete_user(sender, instance, *args, **kwargs): logger.debug("Received pre_delete from %s" % instance) diff --git a/services/tasks.py b/services/tasks.py index 5b36e6ab..dbd609a3 100644 --- a/services/tasks.py +++ b/services/tasks.py @@ -1,36 +1,19 @@ from __future__ import unicode_literals -from django.conf import settings + import logging -from django.contrib.auth.models import User + from celery import task -from services.models import UserTSgroup -from services.models import AuthTS -from services.models import TSgroup -from services.models import MumbleUser -from authentication.managers import AuthServicesInfoManager -from authentication.models import AuthServicesInfo -from services.managers.openfire_manager import OpenfireManager -from services.managers.phpbb3_manager import Phpbb3Manager -from services.managers.mumble_manager import MumbleManager -from services.managers.ipboard_manager import IPBoardManager -from services.managers.teamspeak3_manager import Teamspeak3Manager -from services.managers.discord_manager import DiscordOAuthManager -from services.managers.xenforo_manager import XenForoManager -from services.managers.market_manager import marketManager -from services.managers.discourse_manager import DiscourseManager -from services.managers.smf_manager import smfManager -from services.managers.util.ts3 import TeamspeakError + +from alliance_auth.hooks import get_hooks from authentication.states import MEMBER_STATE, BLUE_STATE from notifications import notify -from celery.task import periodic_task -from celery.task.schedules import crontab -from eveonline.managers import EveManager import redis REDIS_CLIENT = redis.Redis() logger = logging.getLogger(__name__) + # http://loose-bits.com/2010/10/distributed-task-locking-in-celery.html def only_one(function=None, key="", timeout=None): """Enforce only one celery task at a time.""" @@ -58,169 +41,17 @@ def only_one(function=None, key="", timeout=None): return _dec(function) if function is not None else _dec -@periodic_task(run_every=crontab(minute="*/30")) -def run_ts3_group_update(): - if settings.ENABLE_AUTH_TEAMSPEAK3 or settings.ENABLE_BLUE_TEAMSPEAK3: - logger.debug("TS3 installed. Syncing local group objects.") - Teamspeak3Manager._sync_ts_group_db() - - -def disable_teamspeak(): - if settings.ENABLE_AUTH_TEAMSPEAK3: - logger.warn( - "ENABLE_AUTH_TEAMSPEAK3 still True, after disabling users will still be able to create teamspeak accounts") - if settings.ENABLE_BLUE_TEAMSPEAK3: - logger.warn( - "ENABLE_BLUE_TEAMSPEAK3 still True, after disabling blues will still be able to create teamspeak accounts") - for auth in AuthServicesInfo.objects.all(): - if auth.teamspeak3_uid: - logger.info("Clearing %s Teamspeak3 UID" % auth.user) - auth.teamspeak3_uid = '' - auth.save() - if auth.teamspeak3_perm_key: - logger.info("Clearing %s Teamspeak3 permission key" % auth.user) - auth.teamspeak3_perm_key = '' - auth.save() - logger.info("Deleting all UserTSgroup models") - UserTSgroup.objects.all().delete() - logger.info("Deleting all AuthTS models") - AuthTS.objects.all().delete() - logger.info("Deleting all TSgroup models") - TSgroup.objects.all().delete() - logger.info("Teamspeak3 disabled") - - -def disable_forum(): - if settings.ENABLE_AUTH_FORUM: - logger.warn("ENABLE_AUTH_FORUM still True, after disabling users will still be able to create forum accounts") - if settings.ENABLE_BLUE_FORUM: - logger.warn("ENABLE_BLUE_FORUM still True, after disabling blues will still be able to create forum accounts") - for auth in AuthServicesInfo.objects.all(): - if auth.forum_username: - logger.info("Clearing %s forum username" % auth.user) - auth.forum_username = '' - auth.save() - - -def disable_jabber(): - if settings.ENABLE_AUTH_JABBER: - logger.warn("ENABLE_AUTH_JABBER still True, after disabling users will still be able to create jabber accounts") - if settings.ENABLE_BLUE_JABBER: - logger.warn("ENABLE_BLUE_JABBER still True, after disabling blues will still be able to create jabber accounts") - for auth in AuthServicesInfo.objects.all(): - if auth.jabber_username: - logger.info("Clearing %s jabber username" % auth.user) - auth.jabber_username = '' - auth.save() - - -def disable_mumble(): - if settings.ENABLE_AUTH_MUMBLE: - logger.warn("ENABLE_AUTH_MUMBLE still True, after disabling users will still be able to create mumble accounts") - if settings.ENABLE_BLUE_MUMBLE: - logger.warn("ENABLE_BLUE_MUMBLE still True, after disabling blues will still be able to create mumble accounts") - for auth in AuthServicesInfo.objects.all(): - if auth.mumble_username: - logger.info("Clearing %s mumble username" % auth.user) - auth.mumble_username = '' - auth.save() - logger.info("Deleting all MumbleUser models") - MumbleUser.objects.all().delete() - - -def disable_ipboard(): - if settings.ENABLE_AUTH_IPBOARD: - logger.warn( - "ENABLE_AUTH_IPBOARD still True, after disabling users will still be able to create IPBoard accounts") - if settings.ENABLE_BLUE_IPBOARD: - logger.warn( - "ENABLE_BLUE_IPBOARD still True, after disabling blues will still be able to create IPBoard accounts") - for auth in AuthServicesInfo.objects.all(): - if auth.ipboard_username: - logger.info("Clearing %s ipboard username" % auth.user) - auth.ipboard_username = '' - auth.save() - - -def disable_discord(): - if settings.ENABLE_AUTH_DISCORD: - logger.warn("ENABLE_AUTH_DISCORD still True, after disabling users will still be able to link Discord accounts") - if settings.ENABLE_BLUE_DISCORD: - logger.warn("ENABLE_BLUE_DISCORD still True, after disabling blues will still be able to link Discord accounts") - for auth in AuthServicesInfo.objects.all(): - if auth.discord_uid: - logger.info("Clearing %s Discord UID" % auth.user) - auth.discord_uid = '' - auth.save() - - -def disable_market(): - if settings.ENABLE_AUTH_MARKET: - logger.warn("ENABLE_AUTH_MARKET still True, after disabling users will still be able to activate Market accounts") - if settings.ENABLE_BLUE_DISCORD: - logger.warn("ENABLE_BLUE_MARKET still True, after disabling blues will still be able to activate Market accounts") - for auth in AuthServicesInfo.objects.all(): - if auth.market_username: - logger.info("Clearing %s market username" % auth.user) - auth.market_username = '' - auth.save() - - def deactivate_services(user): change = False logger.debug("Deactivating services for user %s" % user) - authinfo = AuthServicesInfo.objects.get(user=user) - if authinfo.mumble_username and authinfo.mumble_username != "": - logger.debug("User %s has mumble account %s. Deleting." % (user, authinfo.mumble_username)) - MumbleManager.delete_user(authinfo.mumble_username) - AuthServicesInfoManager.update_user_mumble_info("", user) - change = True - if authinfo.jabber_username and authinfo.jabber_username != "": - logger.debug("User %s has jabber account %s. Deleting." % (user, authinfo.jabber_username)) - OpenfireManager.delete_user(authinfo.jabber_username) - AuthServicesInfoManager.update_user_jabber_info("", user) - change = True - if authinfo.forum_username and authinfo.forum_username != "": - logger.debug("User %s has forum account %s. Deleting." % (user, authinfo.forum_username)) - Phpbb3Manager.disable_user(authinfo.forum_username) - AuthServicesInfoManager.update_user_forum_info("", user) - change = True - if authinfo.ipboard_username and authinfo.ipboard_username != "": - logger.debug("User %s has ipboard account %s. Deleting." % (user, authinfo.ipboard_username)) - IPBoardManager.disable_user(authinfo.ipboard_username) - AuthServicesInfoManager.update_user_ipboard_info("", user) - change = True - if authinfo.teamspeak3_uid and authinfo.teamspeak3_uid != "": - logger.debug("User %s has mumble account %s. Deleting." % (user, authinfo.teamspeak3_uid)) - Teamspeak3Manager.delete_user(authinfo.teamspeak3_uid) - AuthServicesInfoManager.update_user_teamspeak3_info("", "", user) - change = True - if authinfo.discord_uid and authinfo.discord_uid != "": - logger.debug("User %s has discord account %s. Deleting." % (user, authinfo.discord_uid)) - DiscordOAuthManager.delete_user(authinfo.discord_uid) - AuthServicesInfoManager.update_user_discord_info("", user) - change = True - if authinfo.xenforo_username and authinfo.xenforo_password != "": - logger.debug("User %s has a XenForo account %s. Deleting." % (user, authinfo.xenforo_username)) - XenForoManager.disable_user(authinfo.xenforo_username) - AuthServicesInfoManager.update_user_xenforo_info("", user) - change = True - if authinfo.market_username and authinfo.market_username != "": - logger.debug("User %s has a Market account %s. Deleting." % (user, authinfo.market_username)) - marketManager.disable_user(authinfo.market_username) - AuthServicesInfoManager.update_user_market_info("", user) - change = True - if authinfo.discourse_enabled: - logger.debug("User %s has a Discourse account. Disabling login." % user) - DiscourseManager.disable_user(user) - authinfo.discourse_enabled = False - authinfo.save() - change = True - if authinfo.smf_username and authinfo.smf_username != "": - logger.debug("User %s has a SMF account %s. Deleting." % (user, authinfo.smf_username)) - smfManager.disable_user(authinfo.smf_username) - AuthServicesInfoManager.update_user_smf_info("", user) - change = True + # Iterate through service hooks and disable users + for fn in get_hooks('services_hook'): + svc = fn() + try: + if svc.delete_user(user): + change = True + except: + logger.exception('Exception running delete_user for services module %s on user %s' % (svc, user)) if change: notify(user, "Services Disabled", message="Your services accounts have been disabled.", level="danger") @@ -235,273 +66,10 @@ def validate_services(self, user, state): deactivate_services(user) return logger.debug('Ensuring user %s services are available to state %s' % (user, state)) - auth = AuthServicesInfo.objects.get(user=user) - if auth.mumble_username and not getattr(settings, 'ENABLE_%s_MUMBLE' % setting_string, False): - MumbleManager.delete_user(auth.mumble_username) - AuthServicesInfoManager.update_user_mumble_info("", user) - notify(user, 'Mumble Account Disabled', level='danger') - if auth.jabber_username and not getattr(settings, 'ENABLE_%s_JABBER' % setting_string, False): - OpenfireManager.delete_user(auth.jabber_username) - AuthServicesInfoManager.update_user_jabber_info("", user) - notify(user, 'Jabber Account Disabled', level='danger') - if auth.forum_username and not getattr(settings, 'ENABLE_%s_FORUM' % setting_string, False): - Phpbb3Manager.disable_user(auth.forum_username) - AuthServicesInfoManager.update_user_forum_info("", user) - notify(user, 'Forum Account Disabled', level='danger') - if auth.ipboard_username and not getattr(settings, 'ENABLE_%s_IPBOARD' % setting_string, False): - IPBoardManager.disable_user(auth.ipboard_username) - AuthServicesInfoManager.update_user_ipboard_info("", user) - notify(user, 'IPBoard Account Disabled', level='danger') - if auth.teamspeak3_uid and not getattr(settings, 'ENABLE_%s_TEAMSPEAK' % setting_string, False): - Teamspeak3Manager.delete_user(auth.teamspeak3_uid) - AuthServicesInfoManager.update_user_teamspeak3_info("", "", user) - notify(user, 'TeamSpeak3 Account Disabled', level='danger') - if auth.discord_uid and not getattr(settings, 'ENABLE_%s_DISCORD' % setting_string, False): - DiscordOAuthManager.delete_user(auth.discord_uid) - AuthServicesInfoManager.update_user_discord_info("", user) - notify(user, 'Discord Account Disabled', level='danger') - if auth.xenforo_username and not getattr(settings, 'ENABLE_%s_XENFORO' % setting_string, False): - XenForoManager.disable_user(auth.xenforo_username) - AuthServicesInfoManager.update_user_xenforo_info("", user) - notify(user, 'XenForo Account Disabled', level='danger') - if auth.market_username and not getattr(settings, 'ENABLE_%s_MARKET' % setting_string, False): - marketManager.disable_user(auth.market_username) - AuthServicesInfoManager.update_user_market_info("", user) - notify(user, 'Alliance Market Account Disabled', level='danger') - if auth.discourse_enabled and not getattr(settings, 'ENABLE_%s_DISCOURSE' % setting_string, False): - DiscourseManager.disable_user(user) - authinfo.discourse_enabled = False - authinfo.save() - notify(user, 'Discourse Account Disabled', level='danger') - if auth.smf_username and not getattr(settings, 'ENABLE_%s_SMF' % setting_string, False): - smfManager.disable_user(auth.smf_username) - AuthServicesInfoManager.update_user_smf_info(auth.smf_username, user) - notify(user, "SMF Account Disabled", level='danger') - - -@task(bind=True) -def update_jabber_groups(self, pk): - user = User.objects.get(pk=pk) - logger.debug("Updating jabber groups for user %s" % user) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - groups = [] - for group in user.groups.all(): - groups.append(str(group.name)) - if len(groups) == 0: - groups.append('empty') - logger.debug("Updating user %s jabber groups to %s" % (user, groups)) - try: - OpenfireManager.update_user_groups(authserviceinfo.jabber_username, groups) - except: - logger.exception("Jabber group sync failed for %s, retrying in 10 mins" % user) - raise self.retry(countdown=60 * 10) - logger.debug("Updated user %s jabber groups." % user) - - -@task -def update_all_jabber_groups(): - logger.debug("Updating ALL jabber groups") - for user in AuthServicesInfo.objects.exclude(jabber_username__exact=''): - update_jabber_groups.delay(user.user_id) - - -@task(bind=True) -def update_mumble_groups(self, pk): - user = User.objects.get(pk=pk) - logger.debug("Updating mumble groups for user %s" % user) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - groups = [] - for group in user.groups.all(): - groups.append(str(group.name)) - if len(groups) == 0: - groups.append('empty') - logger.debug("Updating user %s mumble groups to %s" % (user, groups)) - try: - MumbleManager.update_groups(authserviceinfo.mumble_username, groups) - except: - logger.exception("Mumble group sync failed for %s, retrying in 10 mins" % user) - raise self.retry(countdown=60 * 10) - logger.debug("Updated user %s mumble groups." % user) - - -@task -def update_all_mumble_groups(): - logger.debug("Updating ALL mumble groups") - for user in AuthServicesInfo.objects.exclude(mumble_username__exact=''): - update_mumble_groups.delay(user.user_id) - - -@task(bind=True) -def update_forum_groups(self, pk): - user = User.objects.get(pk=pk) - logger.debug("Updating forum groups for user %s" % user) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - groups = [] - for group in user.groups.all(): - groups.append(str(group.name)) - if len(groups) == 0: - groups.append('empty') - logger.debug("Updating user %s forum groups to %s" % (user, groups)) - try: - Phpbb3Manager.update_groups(authserviceinfo.forum_username, groups) - except: - logger.exception("Phpbb group sync failed for %s, retrying in 10 mins" % user) - raise self.retry(countdown=60 * 10) - logger.debug("Updated user %s forum groups." % user) - - -@task -def update_all_forum_groups(): - logger.debug("Updating ALL forum groups") - for user in AuthServicesInfo.objects.exclude(forum_username__exact=''): - update_forum_groups.delay(user.user_id) - - -@task(bind=True) -def update_smf_groups(self, pk): - user = User.objects.get(pk=pk) - logger.debug("Updating smf groups for user %s" % user) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - groups = [] - for group in user.groups.all(): - groups.append(str(group.name)) - if len(groups) == 0: - groups.append('empty') - logger.debug("Updating user %s smf groups to %s" % (user, groups)) - try: - smfManager.update_groups(authserviceinfo.smf_username, groups) - except: - logger.exception("smf group sync failed for %s, retrying in 10 mins" % user) - raise self.retry(countdown=60 * 10) - logger.debug("Updated user %s smf groups." % user) - - -@task -def update_all_smf_groups(): - logger.debug("Updating ALL smf groups") - for user in AuthServicesInfo.objects.exclude(smf_username__exact=''): - update_smf_groups.delay(user.user_id) - - -@task(bind=True) -def update_ipboard_groups(self, pk): - user = User.objects.get(pk=pk) - logger.debug("Updating user %s ipboard groups." % user) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - groups = [] - for group in user.groups.all(): - groups.append(str(group.name)) - if len(groups) == 0: - groups.append('empty') - logger.debug("Updating user %s ipboard groups to %s" % (user, groups)) - try: - IPBoardManager.update_groups(authserviceinfo.ipboard_username, groups) - except: - logger.exception("IPBoard group sync failed for %s, retrying in 10 mins" % user) - raise self.retry(countdown=60 * 10) - logger.debug("Updated user %s ipboard groups." % user) - - -@task -def update_all_ipboard_groups(): - logger.debug("Updating ALL ipboard groups") - for user in AuthServicesInfo.objects.exclude(ipboard_username__exact=''): - update_ipboard_groups.delay(user.user_id) - - -@task(bind=True) -def update_teamspeak3_groups(self, pk): - user = User.objects.get(pk=pk) - logger.debug("Updating user %s teamspeak3 groups" % user) - usergroups = user.groups.all() - authserviceinfo = AuthServicesInfo.objects.get(user=user) - groups = {} - for usergroup in usergroups: - filtered_groups = AuthTS.objects.filter(auth_group=usergroup) - if filtered_groups: - for filtered_group in filtered_groups: - for ts_group in filtered_group.ts_group.all(): - groups[ts_group.ts_group_name] = ts_group.ts_group_id - logger.debug("Updating user %s teamspeak3 groups to %s" % (user, groups)) - try: - Teamspeak3Manager.update_groups(authserviceinfo.teamspeak3_uid, groups) - logger.debug("Updated user %s teamspeak3 groups." % user) - except TeamspeakError as e: - logger.error("Error occured while syncing TS groups for %s: %s" % (user, str(e))) - raise self.retry(countdown=60*10) - - -@task -def update_all_teamspeak3_groups(): - logger.debug("Updating ALL teamspeak3 groups") - for user in AuthServicesInfo.objects.exclude(teamspeak3_uid__exact=''): - update_teamspeak3_groups.delay(user.user_id) - - -@task(bind=True) -@only_one(key="Discord", timeout=60*5) -def update_discord_groups(self, pk): - user = User.objects.get(pk=pk) - logger.debug("Updating discord groups for user %s" % user) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - groups = [] - for group in user.groups.all(): - groups.append(str(group.name)) - if len(groups) == 0: - logger.debug("No syncgroups found for user. Adding empty group.") - groups.append('empty') - logger.debug("Updating user %s discord groups to %s" % (user, groups)) - try: - DiscordOAuthManager.update_groups(authserviceinfo.discord_uid, groups) - except: - logger.exception("Discord group sync failed for %s, retrying in 10 mins" % user) - raise self.retry(countdown=60 * 10) - logger.debug("Updated user %s discord groups." % user) - - -@task -def update_all_discord_groups(): - logger.debug("Updating ALL discord groups") - for user in AuthServicesInfo.objects.exclude(discord_uid__exact=''): - update_discord_groups.delay(user.user_id) - - -@task(bind=True) -def update_discord_nickname(self, pk): - user = User.objects.get(pk=pk) - logger.debug("Updating discord nickname for user %s" % user) - authserviceinfo = AuthServicesInfo.objects.get(user=user) - character = EveManager.get_character_by_id(authserviceinfo.main_char_id) - logger.debug("Updating user %s discord nickname to %s" % (user, character.character_name)) - try: - DiscordOAuthManager.update_nickname(authserviceinfo.discord_uid, character.character_name) - except: - logger.exception("Discord nickname sync failed for %s, retrying in 10 mins" % user) - raise self.retry(countdown=60 * 10) - logger.debug("Updated user %s discord nickname." % user) - - -@task -def update_all_discord_nicknames(): - logger.debug("Updating ALL discord nicknames") - for user in AuthServicesInfo.objects.exclude(discord_uid__exact=''): - update_discord_nickname(user.user_id) - - -@task(bind=True) -def update_discourse_groups(self, pk): - user = User.objects.get(pk=pk) - logger.debug("Updating discourse groups for user %s" % user) - try: - DiscourseManager.update_groups(user) - except: - logger.warn("Discourse group sync failed for %s, retrying in 10 mins" % user) - raise self.retry(countdown=60 * 10) - logger.debug("Updated user %s discourse groups." % user) - - -@task -def update_all_discourse_groups(): - logger.debug("Updating ALL discourse groups") - for user in AuthServicesInfo.objects.filter(discourse_enabled=True): - update_discourse_groups.delay(user.pk) + # Iterate through services hooks and have them check the validity of the user + for fn in get_hooks('services_hook'): + svc = fn() + try: + svc.validate_user(user) + except: + logger.exception('Exception running validate_user for services module %s on user %s' % (svc, user)) diff --git a/services/templates/public/menublock.html b/services/templates/public/menublock.html new file mode 100644 index 00000000..a0a4637a --- /dev/null +++ b/services/templates/public/menublock.html @@ -0,0 +1,3 @@ +{% for item in menu_items %} + {{ item }} +{% endfor %} diff --git a/services/templates/public/menuitem.html b/services/templates/public/menuitem.html new file mode 100644 index 00000000..12493751 --- /dev/null +++ b/services/templates/public/menuitem.html @@ -0,0 +1,8 @@ +{% load i18n %} +{% load navactive %} + +
  • + + {% trans item.text %} + +
  • diff --git a/services/templatetags/__init__.py b/services/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/templatetags/menu_items.py b/services/templatetags/menu_items.py new file mode 100644 index 00000000..5928ca6e --- /dev/null +++ b/services/templatetags/menu_items.py @@ -0,0 +1,43 @@ +from __future__ import unicode_literals + +from django import template + +from alliance_auth.hooks import get_hooks + +register = template.Library() + + +def process_menu_items(hooks, request): + _menu_items = list() + items = [fn() for fn in hooks] + items.sort(key=lambda i: i.order) + for item in items: + _menu_items.append(item.render(request)) + return _menu_items + + +@register.inclusion_tag('public/menublock.html', takes_context=True) +def menu_main(context): + request = context['request'] + + return { + 'menu_items': process_menu_items(get_hooks('menu_main_hook'), request), + } + + +@register.inclusion_tag('public/menublock.html', takes_context=True) +def menu_aux(context): + request = context['request'] + + return { + 'menu_items': process_menu_items(get_hooks('menu_aux_hook'), request), + } + + +@register.inclusion_tag('public/menublock.html', takes_context=True) +def menu_util(context): + request = context['request'] + + return { + 'menu_items': process_menu_items(get_hooks('menu_util_hook'), request), + } diff --git a/services/tests.py b/services/tests.py deleted file mode 100644 index a39b155a..00000000 --- a/services/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/services/tests/__init__.py b/services/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/services/tests/test_signals.py b/services/tests/test_signals.py new file mode 100644 index 00000000..302e9a62 --- /dev/null +++ b/services/tests/test_signals.py @@ -0,0 +1,91 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase +from django.contrib.auth.models import Group + +from alliance_auth.tests.auth_utils import AuthUtils + + +class ServicesSignalsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.none_user = AuthUtils.create_user('none_user', disconnect_signals=True) + + @mock.patch('services.signals.transaction') + @mock.patch('services.signals.get_hooks') + def test_m2m_changed_user_groups(self, get_hooks, transaction): + """ + Test that update_groups hook function is called on user groups change + """ + svc = mock.Mock() + svc.update_groups.return_value = None + + get_hooks.return_value = [lambda: svc] + + # Overload transaction.on_commit so everything happens synchronously + transaction.on_commit = lambda fn: fn() + + test_group = Group.objects.create(name="Test group") + + # Act, should trigger m2m change + self.member.groups.add(test_group) + self.member.save() + + # Assert + self.assertTrue(get_hooks.called) + args, kwargs = get_hooks.call_args + self.assertEqual('services_hook', args[0]) + + self.assertTrue(svc.update_groups.called) + args, kwargs = svc.update_groups.call_args + self.assertEqual(self.member, args[0]) + + @mock.patch('services.signals.disable_member') + def test_pre_delete_user(self, disable_member): + """ + Test that disable_member is called when a user is deleted + """ + self.none_user.delete() + + self.assertTrue(disable_member.called) + args, kwargs = disable_member.call_args + self.assertEqual(self.none_user, args[0]) + + @mock.patch('services.signals.disable_member') + def test_pre_save_user_inactivation(self, disable_member): + """ + Test a user set inactive has disable_member called + """ + self.member.is_active = False + self.member.save() # Signal Trigger + + self.assertTrue(disable_member.called) + args, kwargs = disable_member.call_args + self.assertEqual(self.member, args[0]) + + @mock.patch('services.signals.set_state') + def test_pre_save_user_activation(self, set_state): + """ + Test a user set inactive has disable_member called + """ + # Arrange, set user inactive first + self.member.is_active = False + self.member.save() # Signal Trigger (but not the one we want) + + set_state.reset_mock() + + # Act + self.member.is_active = True + self.member.save() # Signal Trigger + + # Assert + self.assertTrue(set_state.called) + args, kwargs = set_state.call_args + self.assertEqual(self.member, args[0]) diff --git a/services/tests/test_tasks.py b/services/tests/test_tasks.py new file mode 100644 index 00000000..bb307d0a --- /dev/null +++ b/services/tests/test_tasks.py @@ -0,0 +1,81 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase + +from authentication.states import MEMBER_STATE, BLUE_STATE, NONE_STATE +from alliance_auth.tests.auth_utils import AuthUtils + +from services.tasks import deactivate_services, validate_services + + +class ServicesTasksTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.none_user = AuthUtils.create_user('none_user', disconnect_signals=True) + + @mock.patch('services.tasks.get_hooks') + @mock.patch('services.tasks.deactivate_services') + def test_validate_services_deactivate(self, deactivate_services, get_hooks): + """ + Test validate services will call deactivate on a None state user + """ + + validate_services.delay(user=self.none_user, state=NONE_STATE) + + self.assertTrue(deactivate_services.called) + args, kwargs = deactivate_services.call_args + self.assertEqual(self.none_user, args[0]) # Assert correct user is passed + self.assertFalse(get_hooks.called) + + @mock.patch('services.tasks.get_hooks') + @mock.patch('services.tasks.deactivate_services') + def test_validate_services_valid_member(self, deactivate_services, get_hooks): + """ + Test that validate_services is called for a valid member + """ + svc = mock.Mock() + svc.validate_user.return_value = None + + get_hooks.return_value = [lambda: svc] + + validate_services.delay(user=self.member, state=MEMBER_STATE) + + self.assertTrue(get_hooks.called) + args, kwargs = get_hooks.call_args + self.assertEqual('services_hook', args[0]) + self.assertTrue(svc.validate_user.called) + args, kwargs = svc.validate_user.call_args + self.assertEqual(self.member, args[0]) # Assert correct user is passed to service hook function + self.assertFalse(deactivate_services.called) + + @mock.patch('services.tasks.notify') + @mock.patch('services.tasks.get_hooks') + def test_deactivate_services(self, get_hooks, notify): + """ + Test that hooks delete_user function is called by deactivate_services + """ + svc = mock.Mock() + svc.delete_user.return_value = True + + get_hooks.return_value = [lambda: svc] + + deactivate_services(self.member) + + self.assertTrue(get_hooks.called) + args, kwargs = get_hooks.call_args + self.assertEqual('services_hook', args[0]) + self.assertTrue(svc.delete_user.called) + args, kwargs = svc.delete_user.call_args + self.assertEqual(self.member, args[0]) # Assert correct user is passed to service hook function + self.assertTrue(notify.called) + args, kwargs = notify.call_args + self.assertEqual(self.member, args[0]) # Assert user is passed to the notification system + self.assertEqual("Services Disabled", args[1]) + self.assertEqual("danger", kwargs['level']) diff --git a/services/views.py b/services/views.py index d25fd2b7..7f891530 100755 --- a/services/views.py +++ b/services/views.py @@ -1,56 +1,14 @@ from __future__ import unicode_literals -from django.shortcuts import render, redirect -from django.contrib.auth.decorators import login_required -from django.contrib.auth.decorators import permission_required -from django.contrib.auth.decorators import user_passes_test -from django.contrib.auth.models import Group -from django.conf import settings + from django.contrib import messages -from eveonline.models import EveCharacter -from eveonline.models import EveAllianceInfo -from authentication.models import AuthServicesInfo -from services.managers.openfire_manager import OpenfireManager -from services.managers.phpbb3_manager import Phpbb3Manager -from services.managers.mumble_manager import MumbleManager -from services.managers.ipboard_manager import IPBoardManager -from services.managers.xenforo_manager import XenForoManager -from services.managers.teamspeak3_manager import Teamspeak3Manager -from services.managers.discord_manager import DiscordOAuthManager -from services.managers.discourse_manager import DiscourseManager -from services.managers.ips4_manager import Ips4Manager -from services.managers.smf_manager import smfManager -from services.managers.market_manager import marketManager -from authentication.managers import AuthServicesInfoManager -from eveonline.managers import EveManager -from services.tasks import update_jabber_groups -from services.tasks import update_mumble_groups -from services.tasks import update_forum_groups -from services.tasks import update_ipboard_groups -from services.tasks import update_smf_groups -from services.tasks import update_teamspeak3_groups -from services.tasks import update_discord_groups -from services.tasks import update_discord_nickname -from services.tasks import update_discourse_groups -from services.forms import JabberBroadcastForm -from services.forms import FleetFormatterForm -from services.forms import ServicePasswordForm -from services.forms import TeamspeakJoinForm +from django.contrib.auth.decorators import login_required +from django.shortcuts import render + +from alliance_auth.hooks import get_hooks from authentication.decorators import members_and_blues -from authentication.states import MEMBER_STATE, BLUE_STATE, NONE_STATE - -import base64 -import hmac -import hashlib -try: - from urllib import unquote, urlencode -except ImportError: #py3 - from urllib.parse import unquote, urlencode -try: - from urlparse import parse_qs -except ImportError: #py3 - from urllib.parse import parse_qs - -import datetime +from authentication.models import AuthServicesInfo +from eveonline.models import EveCharacter +from services.forms import FleetFormatterForm import logging @@ -87,57 +45,6 @@ def fleet_formatter_view(request): return render(request, 'registered/fleetformattertool.html', context=context) -@login_required -@permission_required('auth.jabber_broadcast') -def jabber_broadcast_view(request): - logger.debug("jabber_broadcast_view called by user %s" % request.user) - allchoices = [] - if request.user.has_perm('auth.jabber_broadcast_all'): - allchoices.append(('all', 'all')) - for g in Group.objects.all(): - allchoices.append((str(g.name), str(g.name))) - else: - for g in request.user.groups.all(): - allchoices.append((str(g.name), str(g.name))) - if request.method == 'POST': - form = JabberBroadcastForm(request.POST) - form.fields['group'].choices = allchoices - logger.debug("Received POST request containing form, valid: %s" % form.is_valid()) - if form.is_valid(): - user_info = AuthServicesInfo.objects.get(user=request.user) - main_char = EveCharacter.objects.get(character_id=user_info.main_char_id) - logger.debug("Processing jabber broadcast for user %s with main character %s" % (user_info, main_char)) - if user_info.main_char_id != "": - message_to_send = form.cleaned_data[ - 'message'] + "\n##### SENT BY: " + "[" + main_char.corporation_ticker + "]" + \ - main_char.character_name + " TO: " + \ - form.cleaned_data['group'] + " WHEN: " + datetime.datetime.utcnow().strftime( - "%Y-%m-%d %H:%M:%S") + " #####\n##### Replies are NOT monitored #####\n" - group_to_send = form.cleaned_data['group'] - - OpenfireManager.send_broadcast_threaded(group_to_send, message_to_send, ) - - else: - message_to_send = form.cleaned_data[ - 'message'] + "\n##### SENT BY: " + "No character but can send pings?" + " TO: " + \ - form.cleaned_data['group'] + " WHEN: " + datetime.datetime.utcnow().strftime( - "%Y-%m-%d %H:%M:%S") + " #####\n##### Replies are NOT monitored #####\n" - group_to_send = form.cleaned_data['group'] - - OpenfireManager.send_broadcast_threaded(group_to_send, message_to_send, ) - - messages.success(request, 'Sent jabber broadcast to %s' % group_to_send) - logger.info("Sent jabber broadcast on behalf of user %s" % request.user) - else: - form = JabberBroadcastForm() - form.fields['group'].choices = allchoices - logger.debug("Generated broadcast form for user %s containing %s groups" % ( - request.user, len(form.fields['group'].choices))) - - context = {'form': form} - return render(request, 'registered/jabberbroadcast.html', context=context) - - @login_required @members_and_blues() def services_view(request): @@ -150,1060 +57,15 @@ def services_view(request): except EveCharacter.DoesNotExist: messages.warning(request, "There's a problem with your main character. Please select a new one.") - services = [ - 'FORUM', - 'JABBER', - 'MUMBLE', - 'IPBOARD', - 'TEAMSPEAK3', - 'DISCORD', - 'DISCOURSE', - 'IPS4', - 'SMF', - 'MARKET', - 'XENFORO', - ] - - context = { - 'authinfo': auth, - 'char': char, - } - - for s in services: - context['SHOW_' + s] = (getattr(settings, 'ENABLE_AUTH_' + s) and ( - auth.state == MEMBER_STATE or request.user.is_superuser)) or (getattr(settings, 'ENABLE_BLUE_' + s) and ( - auth.state == BLUE_STATE or request.user.is_superuser)) + context = {'service_ctrls': []} + for fn in get_hooks('services_hook'): + # Render hooked services controls + svc = fn() + if svc.show_service_ctrl(request.user, auth.state): + context['service_ctrls'].append(svc.render_services_ctrl(request)) return render(request, 'registered/services.html', context=context) def superuser_test(user): return user.is_superuser - - -@login_required -@members_and_blues() -def activate_forum(request): - logger.debug("activate_forum called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - # Valid now we get the main characters - character = EveManager.get_character_by_id(authinfo.main_char_id) - logger.debug("Adding phpbb user for user %s with main character %s" % (request.user, character)) - result = Phpbb3Manager.add_user(character.character_name, request.user.email, ['REGISTERED'], authinfo.main_char_id) - # if empty we failed - if result[0] != "": - AuthServicesInfoManager.update_user_forum_info(result[0], request.user) - logger.debug("Updated authserviceinfo for user %s with forum credentials. Updating groups." % request.user) - update_forum_groups.delay(request.user.pk) - logger.info("Successfully activated forum for user %s" % request.user) - messages.success(request, 'Activated forum account.') - credentials = { - 'username': result[0], - 'password': result[1], - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'Forum'}) - else: - logger.error("Unsuccessful attempt to activate forum for user %s" % request.user) - messages.error(request, 'An error occurred while processing your forum account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def deactivate_forum(request): - logger.debug("deactivate_forum called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = Phpbb3Manager.disable_user(authinfo.forum_username) - # false we failed - if result: - AuthServicesInfoManager.update_user_forum_info("", request.user) - logger.info("Successfully deactivated forum for user %s" % request.user) - messages.success(request, 'Deactivated forum account.') - else: - logger.error("Unsuccessful attempt to activate forum for user %s" % request.user) - messages.error(request, 'An error occurred while processing your forum account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def reset_forum_password(request): - logger.debug("reset_forum_password called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = Phpbb3Manager.update_user_password(authinfo.forum_username, authinfo.main_char_id) - # false we failed - if result != "": - logger.info("Successfully reset forum password for user %s" % request.user) - messages.success(request, 'Reset forum password.') - credentials = { - 'username': authinfo.forum_username, - 'password': result, - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'Forum'}) - else: - logger.error("Unsuccessful attempt to reset forum password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your forum account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def activate_xenforo_forum(request): - logger.debug("activate_xenforo_forum called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - character = EveManager.get_character_by_id(authinfo.main_char_id) - logger.debug("Adding XenForo user for user %s with main character %s" % (request.user, character)) - result = XenForoManager.add_user(character.character_name, request.user.email) - # Based on XenAPI's response codes - if result['response']['status_code'] == 200: - logger.info("Updated authserviceinfo for user %s with XenForo credentials. Updating groups." % request.user) - AuthServicesInfoManager.update_user_xenforo_info(result['username'], request.user) - messages.success(request, 'Activated XenForo account.') - credentials = { - 'username': result['username'], - 'password': result['password'], - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'XenForo'}) - - else: - logger.error("UnSuccessful attempt to activate xenforo for user %s" % request.user) - messages.error(request, 'An error occurred while processing your XenForo account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def deactivate_xenforo_forum(request): - logger.debug("deactivate_xenforo_forum called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = XenForoManager.disable_user(authinfo.xenforo_username) - if result.status_code == 200: - AuthServicesInfoManager.update_user_xenforo_info("", request.user) - logger.info("Successfully deactivated XenForo for user %s" % request.user) - messages.success(request, 'Deactivated XenForo account.') - else: - messages.error(request, 'An error occurred while processing your XenForo account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def reset_xenforo_password(request): - logger.debug("reset_xenforo_password called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = XenForoManager.reset_password(authinfo.xenforo_username) - # Based on XenAPI's response codes - if result['response']['status_code'] == 200: - logger.info("Successfully reset XenForo password for user %s" % request.user) - messages.success(request, 'Reset XenForo account password.') - credentials = { - 'username': authinfo.xenforo_username, - 'password': result['password'], - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'XenForo'}) - else: - logger.error("Unsuccessful attempt to reset XenForo password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your XenForo account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def set_xenforo_password(request): - logger.debug("set_xenforo_password called by user %s" % request.user) - if request.method == 'POST': - logger.debug("Received POST request with form.") - form = ServicePasswordForm(request.POST) - logger.debug("Form is valid: %s" % form.is_valid()) - if form.is_valid(): - password = form.cleaned_data['password'] - logger.debug("Form contains password of length %s" % len(password)) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = XenForoManager.update_user_password(authinfo.xenforo_username, password) - if result['response']['status_code'] == 200: - logger.info("Successfully reset XenForo password for user %s" % request.user) - messages.success(request, 'Changed XenForo password.') - else: - logger.error("Failed to install custom XenForo password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your XenForo account.') - return redirect('auth_services') - else: - logger.debug("Request is not type POST - providing empty form.") - form = ServicePasswordForm() - - logger.debug("Rendering form for user %s" % request.user) - context = {'form': form, 'service': 'Forum'} - return render(request, 'registered/service_password.html', context=context) - - -@login_required -@members_and_blues() -def activate_ipboard_forum(request): - logger.debug("activate_ipboard_forum called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - # Valid now we get the main characters - character = EveManager.get_character_by_id(authinfo.main_char_id) - logger.debug("Adding ipboard user for user %s with main character %s" % (request.user, character)) - result = IPBoardManager.add_user(character.character_name, request.user.email) - if result[0] != "": - AuthServicesInfoManager.update_user_ipboard_info(result[0], request.user) - logger.debug("Updated authserviceinfo for user %s with ipboard credentials. Updating groups." % request.user) - update_ipboard_groups.delay(request.user.pk) - logger.info("Successfully activated ipboard for user %s" % request.user) - messages.success(request, 'Activated IPBoard account.') - credentials = { - 'username': result[0], - 'password': result[1], - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'IPBoard'}) - else: - logger.error("UnSuccessful attempt to activate ipboard for user %s" % request.user) - messages.error(request, 'An error occurred while processing your IPBoard account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def deactivate_ipboard_forum(request): - logger.debug("deactivate_ipboard_forum called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = IPBoardManager.disable_user(authinfo.ipboard_username) - # false we failed - if result: - AuthServicesInfoManager.update_user_ipboard_info("", request.user) - logger.info("Successfully deactivated ipboard for user %s" % request.user) - messages.success(request, 'Deactivated IPBoard account.') - else: - logger.error("Unsuccessful attempt to deactviate ipboard for user %s" % request.user) - messages.error(request, 'An error occurred while processing your IPBoard account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def reset_ipboard_password(request): - logger.debug("reset_ipboard_password called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = IPBoardManager.update_user_password(authinfo.ipboard_username, request.user.email) - if result != "": - logger.info("Successfully reset ipboard password for user %s" % request.user) - messages.success(request, 'Reset IPBoard password.') - credentials = { - 'username': authinfo.ipboard_username, - 'password': result, - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'IPBoard'}) - else: - logger.error("UnSuccessful attempt to reset ipboard password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your IPBoard account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def activate_jabber(request): - logger.debug("activate_jabber called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - character = EveManager.get_character_by_id(authinfo.main_char_id) - logger.debug("Adding jabber user for user %s with main character %s" % (request.user, character)) - info = OpenfireManager.add_user(character.character_name) - # If our username is blank means we already had a user - if info[0] is not "": - AuthServicesInfoManager.update_user_jabber_info(info[0], request.user) - logger.debug("Updated authserviceinfo for user %s with jabber credentials. Updating groups." % request.user) - update_jabber_groups.delay(request.user.pk) - logger.info("Successfully activated jabber for user %s" % request.user) - messages.success(request, 'Activated jabber account.') - credentials = { - 'username': info[0], - 'password': info[1], - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'Jabber'}) - else: - logger.error("UnSuccessful attempt to activate jabber for user %s" % request.user) - messages.error(request, 'An error occurred while processing your jabber account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def deactivate_jabber(request): - logger.debug("deactivate_jabber called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = OpenfireManager.delete_user(authinfo.jabber_username) - # If our username is blank means we failed - if result: - AuthServicesInfoManager.update_user_jabber_info("", request.user) - logger.info("Successfully deactivated jabber for user %s" % request.user) - messages.success(request, 'Deactivated jabber account.') - else: - logger.error("UnSuccessful attempt to deactivate jabber for user %s" % request.user) - messages.error(request, 'An error occurred while processing your jabber account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def reset_jabber_password(request): - logger.debug("reset_jabber_password called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = OpenfireManager.update_user_pass(authinfo.jabber_username) - # If our username is blank means we failed - if result != "": - AuthServicesInfoManager.update_user_jabber_info(authinfo.jabber_username, request.user) - logger.info("Successfully reset jabber password for user %s" % request.user) - messages.success(request, 'Reset jabber password.') - credentials = { - 'username': authinfo.jabber_username, - 'password': result, - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'Jabber'}) - else: - logger.error("Unsuccessful attempt to reset jabber for user %s" % request.user) - messages.error(request, 'An error occurred while processing your jabber account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def activate_mumble(request): - logger.debug("activate_mumble called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - character = EveManager.get_character_by_id(authinfo.main_char_id) - ticker = character.corporation_ticker - - if authinfo.state == BLUE_STATE: - logger.debug("Adding mumble user for blue user %s with main character %s" % (request.user, character)) - # Blue members should have alliance ticker (if in alliance) - if EveAllianceInfo.objects.filter(alliance_id=character.alliance_id).exists(): - alliance = EveAllianceInfo.objects.filter(alliance_id=character.alliance_id)[0] - ticker = alliance.alliance_ticker - result = MumbleManager.create_blue_user(ticker, character.character_name) - else: - logger.debug("Adding mumble user for user %s with main character %s" % (request.user, character)) - result = MumbleManager.create_user(ticker, character.character_name) - # if its empty we failed - if result[0] is not "": - AuthServicesInfoManager.update_user_mumble_info(result[0], request.user) - logger.debug("Updated authserviceinfo for user %s with mumble credentials. Updating groups." % request.user) - update_mumble_groups.delay(request.user.pk) - logger.info("Successfully activated mumble for user %s" % request.user) - messages.success(request, 'Activated Mumble account.') - credentials = { - 'username': result[0], - 'password': result[1], - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'Mumble'}) - else: - logger.error("Unsuccessful attempt to activate mumble for user %s" % request.user) - messages.error(request, 'An error occurred while processing your Mumble account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def deactivate_mumble(request): - logger.debug("deactivate_mumble called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - # if we successfully remove the user or the user is already removed - if MumbleManager.delete_user(authinfo.mumble_username) or not MumbleManager.user_exists(authinfo.mumble_username): - AuthServicesInfoManager.update_user_mumble_info("", request.user) - logger.info("Successfully deactivated mumble for user %s" % request.user) - messages.success(request, 'Deactivated Mumble account.') - else: - logger.error("Unsuccessful attempt to deactivate mumble for user %s" % request.user) - messages.error(request, 'An error occurred while processing your Mumble account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def reset_mumble_password(request): - logger.debug("reset_mumble_password called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = MumbleManager.update_user_password(authinfo.mumble_username) - - # if blank we failed - if result != "": - logger.info("Successfully reset mumble password for user %s" % request.user) - messages.success(request, 'Reset Mumble password.') - credentials = { - 'username': authinfo.mumble_username, - 'password': result, - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'Mumble'}) - else: - logger.error("UnSuccessful attempt to reset mumble password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your Mumble account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def activate_teamspeak3(request): - logger.debug("activate_teamspeak3 called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - character = EveManager.get_character_by_id(authinfo.main_char_id) - ticker = character.corporation_ticker - - if authinfo.state == BLUE_STATE: - logger.debug("Adding TS3 user for blue user %s with main character %s" % (request.user, character)) - # Blue members should have alliance ticker (if in alliance) - if EveAllianceInfo.objects.filter(alliance_id=character.alliance_id).exists(): - alliance = EveAllianceInfo.objects.filter(alliance_id=character.alliance_id)[0] - ticker = alliance.alliance_ticker - result = Teamspeak3Manager.add_blue_user(character.character_name, ticker) - else: - logger.debug("Adding TS3 user for user %s with main character %s" % (request.user, character)) - result = Teamspeak3Manager.add_user(character.character_name, ticker) - - # if its empty we failed - if result[0] is not "": - AuthServicesInfoManager.update_user_teamspeak3_info(result[0], result[1], request.user) - logger.debug("Updated authserviceinfo for user %s with TS3 credentials. Updating groups." % request.user) - logger.info("Successfully activated TS3 for user %s" % request.user) - messages.success(request, 'Activated TeamSpeak3 account.') - return redirect("auth_verify_teamspeak3") - logger.error("Unsuccessful attempt to activate TS3 for user %s" % request.user) - messages.error(request, 'An error occurred while processing your TeamSpeak3 account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def verify_teamspeak3(request): - logger.debug("verify_teamspeak3 called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - if not authinfo.teamspeak3_uid: - logger.warn("Unable to validate user %s teamspeak: no teamspeak data" % request.user) - return redirect("auth_services") - if request.method == "POST": - form = TeamspeakJoinForm(request.POST) - if form.is_valid(): - update_teamspeak3_groups.delay(request.user.pk) - logger.debug("Validated user %s joined TS server" % request.user) - return redirect("auth_services") - else: - form = TeamspeakJoinForm({'username': authinfo.teamspeak3_uid}) - context = { - 'form': form, - 'authinfo': authinfo, - } - return render(request, 'registered/teamspeakjoin.html', context=context) - - -@login_required -@members_and_blues() -def deactivate_teamspeak3(request): - logger.debug("deactivate_teamspeak3 called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = Teamspeak3Manager.delete_user(authinfo.teamspeak3_uid) - - # if false we failed - if result: - AuthServicesInfoManager.update_user_teamspeak3_info("", "", request.user) - logger.info("Successfully deactivated TS3 for user %s" % request.user) - messages.success(request, 'Deactivated TeamSpeak3 account.') - else: - logger.error("Unsuccessful attempt to deactivate TS3 for user %s" % request.user) - messages.error(request, 'An error occurred while processing your TeamSpeak3 account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def reset_teamspeak3_perm(request): - logger.debug("reset_teamspeak3_perm called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - character = EveManager.get_character_by_id(authinfo.main_char_id) - logger.debug("Deleting TS3 user for user %s" % request.user) - Teamspeak3Manager.delete_user(authinfo.teamspeak3_uid) - - if authinfo.state == BLUE_STATE: - logger.debug( - "Generating new permission key for blue user %s with main character %s" % (request.user, character)) - result = Teamspeak3Manager.generate_new_blue_permissionkey(authinfo.teamspeak3_uid, character.character_name, - character.corporation_ticker) - else: - logger.debug("Generating new permission key for user %s with main character %s" % (request.user, character)) - result = Teamspeak3Manager.generate_new_permissionkey(authinfo.teamspeak3_uid, character.character_name, - character.corporation_ticker) - - # if blank we failed - if result != "": - AuthServicesInfoManager.update_user_teamspeak3_info(result[0], result[1], request.user) - logger.debug("Updated authserviceinfo for user %s with TS3 credentials. Updating groups." % request.user) - update_teamspeak3_groups.delay(request.user.pk) - logger.info("Successfully reset TS3 permission key for user %s" % request.user) - messages.success(request, 'Reset TeamSpeak3 permission key.') - else: - logger.error("Unsuccessful attempt to reset TS3 permission key for user %s" % request.user) - messages.error(request, 'An error occurred while processing your TeamSpeak3 account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def deactivate_discord(request): - logger.debug("deactivate_discord called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = DiscordOAuthManager.delete_user(authinfo.discord_uid) - if result: - AuthServicesInfoManager.update_user_discord_info("", request.user) - logger.info("Successfully deactivated discord for user %s" % request.user) - messages.success(request, 'Deactivated Discord account.') - else: - logger.error("UnSuccessful attempt to deactivate discord for user %s" % request.user) - messages.error(request, 'An error occurred while processing your Discord account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def reset_discord(request): - logger.debug("reset_discord called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = DiscordOAuthManager.delete_user(authinfo.discord_uid) - if result: - AuthServicesInfoManager.update_user_discord_info("", request.user) - logger.info("Successfully deleted discord user for user %s - forwarding to discord activation." % request.user) - return redirect("auth_activate_discord") - logger.error("Unsuccessful attempt to reset discord for user %s" % request.user) - messages.error(request, 'An error occurred while processing your Discord account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def activate_discord(request): - logger.debug("activate_discord called by user %s" % request.user) - return redirect(DiscordOAuthManager.generate_oauth_redirect_url()) - - -@login_required -@members_and_blues() -def discord_callback(request): - logger.debug("Received Discord callback for activation of user %s" % request.user) - code = request.GET.get('code', None) - if not code: - logger.warn("Did not receive OAuth code from callback of user %s" % request.user) - return redirect("auth_services") - user_id = DiscordOAuthManager.add_user(code) - if user_id: - AuthServicesInfoManager.update_user_discord_info(user_id, request.user) - if settings.DISCORD_SYNC_NAMES: - update_discord_nickname.delay(request.user.pk) - update_discord_groups.delay(request.user.pk) - logger.info("Successfully activated Discord for user %s" % request.user) - messages.success(request, 'Activated Discord account.') - else: - logger.error("Failed to activate Discord for user %s" % request.user) - messages.error(request, 'An error occurred while processing your Discord account.') - return redirect("auth_services") - - -@login_required -@user_passes_test(superuser_test) -def discord_add_bot(request): - return redirect(DiscordOAuthManager.generate_bot_add_url()) - - -@login_required -@members_and_blues() -def set_forum_password(request): - logger.debug("set_forum_password called by user %s" % request.user) - if request.method == 'POST': - logger.debug("Received POST request with form.") - form = ServicePasswordForm(request.POST) - logger.debug("Form is valid: %s" % form.is_valid()) - if form.is_valid(): - password = form.cleaned_data['password'] - logger.debug("Form contains password of length %s" % len(password)) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = Phpbb3Manager.update_user_password(authinfo.forum_username, authinfo.main_char_id, - password=password) - if result != "": - logger.info("Successfully set forum password for user %s" % request.user) - messages.success(request, 'Set forum password.') - else: - logger.error("Failed to install custom forum password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your forum account.') - return redirect("auth_services") - else: - logger.debug("Request is not type POST - providing empty form.") - form = ServicePasswordForm() - - logger.debug("Rendering form for user %s" % request.user) - context = {'form': form, 'service': 'Forum'} - return render(request, 'registered/service_password.html', context=context) - - -@login_required -@members_and_blues() -def set_mumble_password(request): - logger.debug("set_mumble_password called by user %s" % request.user) - if request.method == 'POST': - logger.debug("Received POST request with form.") - form = ServicePasswordForm(request.POST) - logger.debug("Form is valid: %s" % form.is_valid()) - if form.is_valid(): - password = form.cleaned_data['password'] - logger.debug("Form contains password of length %s" % len(password)) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = MumbleManager.update_user_password(authinfo.mumble_username, password=password) - if result != "": - logger.info("Successfully reset forum password for user %s" % request.user) - messages.success(request, 'Set Mumble password.') - else: - logger.error("Failed to install custom mumble password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your Mumble account.') - return redirect("auth_services") - else: - logger.debug("Request is not type POST - providing empty form.") - form = ServicePasswordForm() - - logger.debug("Rendering form for user %s" % request.user) - context = {'form': form, 'service': 'Mumble'} - return render(request, 'registered/service_password.html', context=context) - - -@login_required -@members_and_blues() -def set_jabber_password(request): - logger.debug("set_jabber_password called by user %s" % request.user) - if request.method == 'POST': - logger.debug("Received POST request with form.") - form = ServicePasswordForm(request.POST) - logger.debug("Form is valid: %s" % form.is_valid()) - if form.is_valid(): - password = form.cleaned_data['password'] - logger.debug("Form contains password of length %s" % len(password)) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = OpenfireManager.update_user_pass(authinfo.jabber_username, password=password) - if result != "": - logger.info("Successfully set jabber password for user %s" % request.user) - messages.success(request, 'Set jabber password.') - else: - logger.error("Failed to install custom jabber password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your jabber account.') - return redirect("auth_services") - else: - logger.debug("Request is not type POST - providing empty form.") - form = ServicePasswordForm() - - logger.debug("Rendering form for user %s" % request.user) - context = {'form': form, 'service': 'Jabber'} - return render(request, 'registered/service_password.html', context=context) - - -@login_required -@members_and_blues() -def set_ipboard_password(request): - logger.debug("set_ipboard_password called by user %s" % request.user) - error = None - if request.method == 'POST': - logger.debug("Received POST request with form.") - form = ServicePasswordForm(request.POST) - logger.debug("Form is valid: %s" % form.is_valid()) - if form.is_valid(): - password = form.cleaned_data['password'] - logger.debug("Form contains password of length %s" % len(password)) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = IPBoardManager.update_user_password(authinfo.ipboard_username, request.user.email, - plain_password=password) - if result != "": - logger.info("Successfully set IPBoard password for user %s" % request.user) - messages.success(request, 'Set IPBoard password.') - else: - logger.error("Failed to install custom ipboard password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your IPBoard account.') - return redirect("auth_services") - else: - logger.debug("Request is not type POST - providing empty form.") - form = ServicePasswordForm() - - logger.debug("Rendering form for user %s" % request.user) - context = {'form': form, 'service': 'IPBoard', 'error': error} - return render(request, 'registered/service_password.html', context=context) - - -@login_required -@members_and_blues() -def activate_ips4(request): - logger.debug("activate_ips4 called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - # Valid now we get the main characters - character = EveManager.get_character_by_id(authinfo.main_char_id) - logger.debug("Adding IPS4 user for user %s with main character %s" % (request.user, character)) - result = Ips4Manager.add_user(character.character_name, request.user.email) - # if empty we failed - if result[0] != "": - AuthServicesInfoManager.update_user_ips4_info(result[0], result[2], request.user) - logger.debug("Updated authserviceinfo for user %s with IPS4 credentials." % request.user) - # update_ips4_groups.delay(request.user.pk) - logger.info("Successfully activated IPS4 for user %s" % request.user) - messages.success(request, 'Activated IPSuite4 account.') - credentials = { - 'username': result[0], - 'password': result[1], - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'IPSuite4'}) - else: - logger.error("UnSuccessful attempt to activate IPS4 for user %s" % request.user) - messages.error(request, 'An error occurred while processing your IPSuite4 account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def reset_ips4_password(request): - logger.debug("reset_ips4_password called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = Ips4Manager.update_user_password(authinfo.ips4_username) - # false we failed - if result != "": - logger.info("Successfully reset IPS4 password for user %s" % request.user) - messages.success(request, 'Reset IPSuite4 password.') - credentials = { - 'username': authinfo.ips4_username, - 'password': result, - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'IPSuite4'}) - else: - logger.error("Unsuccessful attempt to reset IPS4 password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your IPSuite4 account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def set_ips4_password(request): - logger.debug("set_ips4_password called by user %s" % request.user) - if request.method == 'POST': - logger.debug("Received POST request with form.") - form = ServicePasswordForm(request.POST) - logger.debug("Form is valid: %s" % form.is_valid()) - if form.is_valid(): - password = form.cleaned_data['password'] - logger.debug("Form contains password of length %s" % len(password)) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = Ips4Manager.update_custom_password(authinfo.ips4_username, plain_password=password) - if result != "": - logger.info("Successfully set IPS4 password for user %s" % request.user) - messages.success(request, 'Set IPSuite4 password.') - else: - logger.error("Failed to install custom IPS4 password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your IPSuite4 account.') - return redirect('auth_services') - else: - logger.debug("Request is not type POST - providing empty form.") - form = ServicePasswordForm() - - logger.debug("Rendering form for user %s" % request.user) - context = {'form': form, 'service': 'IPS4'} - return render(request, 'registered/service_password.html', context=context) - - -@login_required -@members_and_blues() -def deactivate_ips4(request): - logger.debug("deactivate_ips4 called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = Ips4Manager.delete_user(authinfo.ips4_id) - if result != "": - AuthServicesInfoManager.update_user_ips4_info("", "", request.user) - logger.info("Successfully deactivated IPS4 for user %s" % request.user) - messages.success(request, 'Deactivated IPSuite4 account.') - else: - logger.error("UnSuccessful attempt to deactivate IPS4 for user %s" % request.user) - messages.error(request, 'An error occurred while processing your IPSuite4 account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def activate_smf(request): - logger.debug("activate_smf called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - # Valid now we get the main characters - character = EveManager.get_character_by_id(authinfo.main_char_id) - logger.debug("Adding smf user for user %s with main character %s" % (request.user, character)) - result = smfManager.add_user(character.character_name, request.user.email, ['Member'], authinfo.main_char_id) - # if empty we failed - if result[0] != "": - AuthServicesInfoManager.update_user_smf_info(result[0], request.user) - logger.debug("Updated authserviceinfo for user %s with smf credentials. Updating groups." % request.user) - update_smf_groups.delay(request.user.pk) - logger.info("Successfully activated smf for user %s" % request.user) - messages.success(request, 'Activated SMF account.') - credentials = { - 'username': result[0], - 'password': result[1], - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'SMF'}) - else: - logger.error("UnSuccessful attempt to activate smf for user %s" % request.user) - messages.error(request, 'An error occurred while processing your SMF account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def deactivate_smf(request): - logger.debug("deactivate_smf called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = smfManager.disable_user(authinfo.smf_username) - # false we failed - if result: - AuthServicesInfoManager.update_user_smf_info("", request.user) - logger.info("Successfully deactivated smf for user %s" % request.user) - messages.success(request, 'Deactivated SMF account.') - else: - logger.error("UnSuccessful attempt to activate smf for user %s" % request.user) - messages.error(request, 'An error occurred while processing your SMF account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def reset_smf_password(request): - logger.debug("reset_smf_password called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = smfManager.update_user_password(authinfo.smf_username, authinfo.main_char_id) - # false we failed - if result != "": - logger.info("Successfully reset smf password for user %s" % request.user) - messages.success(request, 'Reset SMF password.') - credentials = { - 'username': authinfo.smf_username, - 'password': result, - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'SMF'}) - else: - logger.error("Unsuccessful attempt to reset smf password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your SMF account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def set_smf_password(request): - logger.debug("set_smf_password called by user %s" % request.user) - if request.method == 'POST': - logger.debug("Received POST request with form.") - form = ServicePasswordForm(request.POST) - logger.debug("Form is valid: %s" % form.is_valid()) - if form.is_valid(): - password = form.cleaned_data['password'] - logger.debug("Form contains password of length %s" % len(password)) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = smfManager.update_user_password(authinfo.smf_username, authinfo.main_char_id, password=password) - if result != "": - logger.info("Successfully set smf password for user %s" % request.user) - messages.success(request, 'Set SMF password.') - else: - logger.error("Failed to install custom smf password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your SMF account.') - return redirect("auth_services") - else: - logger.debug("Request is not type POST - providing empty form.") - form = ServicePasswordForm() - - logger.debug("Rendering form for user %s" % request.user) - context = {'form': form, 'service': 'SMF'} - return render(request, 'registered/service_password.html', context=context) - - -@login_required -@members_and_blues() -def activate_market(request): - logger.debug("activate_market called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - # Valid now we get the main characters - character = EveManager.get_character_by_id(authinfo.main_char_id) - logger.debug("Adding market user for user %s with main character %s" % (request.user, character)) - result = marketManager.add_user(character.character_name, request.user.email, authinfo.main_char_id, - character.character_name) - # if empty we failed - if result[0] != "": - AuthServicesInfoManager.update_user_market_info(result[0], request.user) - logger.debug("Updated authserviceinfo for user %s with market credentials." % request.user) - logger.info("Successfully activated market for user %s" % request.user) - messages.success(request, 'Activated Alliance Market account.') - credentials = { - 'username': result[0], - 'password': result[1], - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'Alliance Market'}) - else: - logger.error("UnSuccessful attempt to activate market for user %s" % request.user) - messages.error(request, 'An error occurred while processing your Alliance Market account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def deactivate_market(request): - logger.debug("deactivate_market called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = marketManager.disable_user(authinfo.market_username) - # false we failed - if result: - AuthServicesInfoManager.update_user_market_info("", request.user) - logger.info("Successfully deactivated market for user %s" % request.user) - messages.success(request, 'Deactivated Alliance Market account.') - else: - logger.error("UnSuccessful attempt to activate market for user %s" % request.user) - messages.error(request, 'An error occurred while processing your Alliance Market account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def reset_market_password(request): - logger.debug("reset_market_password called by user %s" % request.user) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = marketManager.update_user_password(authinfo.market_username) - # false we failed - if result != "": - logger.info("Successfully reset market password for user %s" % request.user) - messages.success(request, 'Reset Alliance Market password.') - credentials = { - 'username': authinfo.market_username, - 'password': result, - } - return render(request, 'registered/service_credentials.html', - context={'credentials': credentials, 'service': 'Alliance Market'}) - else: - logger.error("Unsuccessful attempt to reset market password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your Alliance Market account.') - return redirect("auth_services") - - -@login_required -@members_and_blues() -def set_market_password(request): - logger.debug("set_market_password called by user %s" % request.user) - if request.method == 'POST': - logger.debug("Received POST request with form.") - form = ServicePasswordForm(request.POST) - logger.debug("Form is valid: %s" % form.is_valid()) - if form.is_valid(): - password = form.cleaned_data['password'] - logger.debug("Form contains password of length %s" % len(password)) - authinfo = AuthServicesInfo.objects.get(user=request.user) - result = marketManager.update_custom_password(authinfo.market_username, password) - if result != "": - logger.info("Successfully reset market password for user %s" % request.user) - messages.success(request, 'Set Alliance Market password.') - else: - logger.error("Failed to install custom market password for user %s" % request.user) - messages.error(request, 'An error occurred while processing your Alliance Market account.') - return redirect("auth_services") - else: - logger.debug("Request is not type POST - providing empty form.") - form = ServicePasswordForm() - - logger.debug("Rendering form for user %s" % request.user) - context = {'form': form, 'service': 'Market'} - return render(request, 'registered/service_password.html', context=context) - - -@login_required -def discourse_sso(request): - - ## Check if user has access - - auth = AuthServicesInfo.objects.get(user=request.user) - if not request.user.is_superuser: - if not settings.ENABLE_AUTH_DISCOURSE and auth.state == MEMBER_STATE: - messages.error(request, 'Members are not authorized to access Discourse.') - return redirect('auth_dashboard') - elif not settings.ENABLE_BLUE_DISCOURSE and auth.state == BLUE_STATE: - messages.error(request, 'Blues are not authorized to access Discourse.') - return redirect('auth_dashboard') - elif auth.state == NONE_STATE: - messages.error(request, 'You are not authorized to access Discourse.') - return redirect('auth_dashboard') - - if not auth.main_char_id: - messages.error(request, "You must have a main character set to access Discourse.") - return redirect('auth_characters') - try: - main_char = EveCharacter.objects.get(character_id=auth.main_char_id) - except EveCharacter.DoesNotExist: - messages.error(request, "Your main character is missing a database model. Please select a new one.") - return redirect('auth_characters') - - payload = request.GET.get('sso') - signature = request.GET.get('sig') - - if None in [payload, signature]: - messages.error(request, 'No SSO payload or signature. Please contact support if this problem persists.') - return redirect('auth_dashboard') - - ## Validate the payload - - try: - payload = unquote(payload).encode('utf-8') - decoded = base64.decodestring(payload).decode('utf-8') - assert 'nonce' in decoded - assert len(payload) > 0 - except AssertionError: - messages.error(request, 'Invalid payload. Please contact support if this problem persists.') - return redirect('auth_dashboard') - - key = str(settings.DISCOURSE_SSO_SECRET).encode('utf-8') - h = hmac.new(key, payload, digestmod=hashlib.sha256) - this_signature = h.hexdigest() - - if this_signature != signature: - messages.error(request, 'Invalid payload. Please contact support if this problem persists.') - return redirect('auth_dashboard') - - ## Build the return payload - - username = DiscourseManager._sanitize_username(main_char.character_name) - - qs = parse_qs(decoded) - params = { - 'nonce': qs['nonce'][0], - 'email': request.user.email, - 'external_id': request.user.pk, - 'username': username, - 'name': username, - } - - if auth.main_char_id: - params['avatar_url'] = 'https://image.eveonline.com/Character/%s_256.jpg' % auth.main_char_id - - return_payload = base64.encodestring(urlencode(params).encode('utf-8')) - h = hmac.new(key, return_payload, digestmod=hashlib.sha256) - query_string = urlencode({'sso': return_payload, 'sig': h.hexdigest()}) - - ## Record activation and queue group sync - - if not auth.discourse_enabled: - auth.discourse_enabled = True - auth.save() - update_discourse_groups.apply_async(args=[request.user.pk], countdown=30) # wait 30s for new user creation on Discourse - - ## Redirect back to Discourse - - url = '%s/session/sso_login' % settings.DISCOURSE_URL - return redirect('%s?%s' % (url, query_string)) diff --git a/stock/templates/public/base.html b/stock/templates/public/base.html index 50bf7fb9..6b8db608 100755 --- a/stock/templates/public/base.html +++ b/stock/templates/public/base.html @@ -1,6 +1,7 @@ {% load staticfiles %} {% load i18n %} {% load navactive %} +{% load menu_items %} @@ -87,7 +88,7 @@ {% else %}
  • {% trans "Login" %}
  • {% endif %} - + @@ -119,7 +120,7 @@ {% trans " Help" %} - + {% menu_main %}
  • {% trans "Aux Navigation" %}
    @@ -194,7 +195,7 @@
  • {% endif %} - + {% menu_aux %}
  • {% trans "Util" %}
  • @@ -211,14 +212,7 @@ {% endif %} - - {% if perms.auth.jabber_broadcast %} -
  • - - {% trans " Jabber Broadcast" %} - -
  • - {% endif %} + {% menu_util %} diff --git a/stock/templates/registered/services.html b/stock/templates/registered/services.html index e3e3423c..11c358b3 100755 --- a/stock/templates/registered/services.html +++ b/stock/templates/registered/services.html @@ -10,14 +10,6 @@ {% block content %}

    {% trans "Available Services" %}

    - {% if SHOW_DISCORD %} - {% if request.user.is_superuser %} - -
    - {% endif %} - {% endif %} @@ -25,263 +17,9 @@ - {% if SHOW_FORUM %} - - - - - - - {% endif %} - {% if SHOW_SMF %} - - - - - - - {% endif %} - {% if SHOW_IPBOARD %} - - - - - - - {% endif %} - {% if SHOW_XENFORO %} - - - - - - - {% endif %} - {% if SHOW_MARKET %} - - - - - - - {% endif %} - {% if SHOW_JABBER %} - - - - - - - {% endif %} - {% if SHOW_MUMBLE %} - - - - - - - {% endif %} - {% if SHOW_IPS4 %} - - - - - {% endif %} - {% if SHOW_DISCORD %} - - - - - - - {% endif %} - {% if SHOW_DISCOURSE %} - - - - - {% endif %} - {% if SHOW_TEAMSPEAK3 %} - - - - - - - - - - - - - - - {% endif %} + {% for svc in service_ctrls %} + {{ svc }} + {% endfor %}
    {% trans "Service" %}{% trans "Domain" %} {% trans "Action" %}
    Forums{{ authinfo.forum_username }}{{ FORUM_URL }} - {% ifequal authinfo.forum_username "" %} - - - - {% else %} - - - - - - - - - - {% endifequal %} -
    SMF Forums{{ authinfo.smf_username }}{{ SMF_URL }} - {% ifequal authinfo.smf_username "" %} - - - - {% else %} - - - - - - - - - - {% endifequal %} -
    IPBoard Forums{{ authinfo.ipboard_username }}{{ FORUM_URL }} - {% ifequal authinfo.ipboard_username "" %} - - - - {% else %} - - - - - - - - - - {% endifequal %} -
    XenForo Forums{{ authinfo.xenforo_username }}{{ FORUM_URL }} - {% ifequal authinfo.xenforo_username "" %} - - - - {% else %} - - - - - - - - - - {% endifequal %} -
    Alliance Market{{ authinfo.market_username }}{{ MARKET_URL }} - {% ifequal authinfo.market_username "" %} - - - - {% else %} - - - - - - - - - - {% endifequal %} -
    Jabber{{ authinfo.jabber_username }}{{ JABBER_URL }} - {% ifequal authinfo.jabber_username "" %} - - - - {% else %} - - - - - - - - - - {% endifequal %} -
    Mumble{{ authinfo.mumble_username }}{{ MUMBLE_URL }} - {% ifequal authinfo.mumble_username "" %} - - - - {% else %} - - - - - - - - - - {% endifequal %} -
    IPS4{{ authinfo.ips4_username }}{{ IPS4_URL }} - {% ifequal authinfo.ips4_username "" %} - - - - {% else %} - - - - - - - - - - {% endifequal %} -
    Discordhttps://discordapp.com - {% ifequal authinfo.discord_uid "" %} - - - - {% else %} - - - - - - - {% endifequal %} -
    Discourse{{ char.character_name }}{{ DISCOURSE_URL }} - -
    {% trans "Service" %}{% trans "Unique ID" %}PermissionKey{% trans "Quick Link" %}{% trans "Action" %}
    Teamspeak 3{{ authinfo.teamspeak3_uid }}{{ authinfo.teamspeak3_perm_key }} - {% ifequal authinfo.teamspeak3_uid "" %} - {% else %} - Teamspeak3 - Link - {% endifequal %} - - {% ifequal authinfo.teamspeak3_uid "" %} - - - - {% else %} - - - - - - - - - {% endifequal %} -
    diff --git a/stock/templates/registered/services_ctrl.html b/stock/templates/registered/services_ctrl.html new file mode 100644 index 00000000..272cf101 --- /dev/null +++ b/stock/templates/registered/services_ctrl.html @@ -0,0 +1,32 @@ +{% load i18n %} + + + {{ service_name }} + {{ username }} + {{ service_url }} + + {% ifequal username "" %} + {% if urls.auth_activate %} + + + + {% endif %} + {% else %} + {% if urls.auth_set_password %} + + + + {% endif %} + {% if urls.auth_reset_password %} + + + + {% endif %} + {% if urls.auth_deactivate %} + + + + {% endif %} + {% endifequal %} + + diff --git a/testing-requirements.txt b/testing-requirements.txt new file mode 100644 index 00000000..2593378d --- /dev/null +++ b/testing-requirements.txt @@ -0,0 +1,9 @@ +# Requirements for running unit tests on this project +# If you aren't intending to run unit tests you can ignore this file +# These packages should be installed in addition to requirements.txt + +mock==2.0.0; python_version < '3.0' +nose>=1.3.7 +django-nose>=1.4.4 +coverage>=4.3.1 +coveralls>=1.1 diff --git a/thirdparty/Mumble/authenticator.py b/thirdparty/Mumble/authenticator.py index 18363500..58275a73 100644 --- a/thirdparty/Mumble/authenticator.py +++ b/thirdparty/Mumble/authenticator.py @@ -521,7 +521,7 @@ def do_main_program(): return (FALL_THROUGH, None, None) try: - sql = 'SELECT id, pwhash, groups FROM %sservices_mumbleuser WHERE username = %%s' % cfg.database.prefix + sql = 'SELECT id, pwhash, groups FROM %smumble_mumbleuser WHERE username = %%s' % cfg.database.prefix cur = threadDB.execute(sql, [name]) except threadDbException: return (FALL_THROUGH, None, None) @@ -571,7 +571,7 @@ def do_main_program(): return FALL_THROUGH try: - sql = 'SELECT id FROM %sservices_mumbleuser WHERE username = %%s' % cfg.database.prefix + sql = 'SELECT id FROM %smumble_mumbleuser WHERE username = %%s' % cfg.database.prefix cur = threadDB.execute(sql, [name]) except threadDbException: return FALL_THROUGH @@ -600,7 +600,7 @@ def do_main_program(): # Fetch the user from the database try: - sql = 'SELECT username FROM %sservices_mumbleuser WHERE id = %%s' % cfg.database.prefix + sql = 'SELECT username FROM %smumble_mumbleuser WHERE id = %%s' % cfg.database.prefix cur = threadDB.execute(sql, [bbid]) except threadDbException: return FALL_THROUGH @@ -666,7 +666,7 @@ def do_main_program(): filter = '%' try: - sql = 'SELECT id, username FROM %sservices_mumbleuser WHERE username LIKE %%s' % cfg.database.prefix + sql = 'SELECT id, username FROM %smumble_mumbleuser WHERE username LIKE %%s' % cfg.database.prefix cur = threadDB.execute(sql, [filter]) except threadDbException: return {} diff --git a/thirdparty/Mumble/requirements.txt b/thirdparty/Mumble/requirements.txt new file mode 100644 index 00000000..b440a025 --- /dev/null +++ b/thirdparty/Mumble/requirements.txt @@ -0,0 +1,3 @@ +bcrypt +passlib +zeroc-ice