diff --git a/README.md b/README.md index e7fac34..8a13efb 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Django-For-Runners for YunoHost -[![Integration level](https://dash.yunohost.org/integration/django-for-runners.svg)](https://dash.yunohost.org/appci/app/django-for-runners) ![](https://ci-apps.yunohost.org/ci/badges/django-for-runners.status.svg) ![](https://ci-apps.yunohost.org/ci/badges/django-for-runners.maintain.svg) +[![Integration level](https://dash.yunohost.org/integration/django-for-runners.svg)](https://dash.yunohost.org/appci/app/django-for-runners) ![](https://ci-apps.yunohost.org/ci/badges/django-for-runners.status.svg) ![](https://ci-apps.yunohost.org/ci/badges/django-for-runners.maintain.svg) [![Install Django-For-Runners with YunoHost](https://install-app.yunohost.org/install-with-yunohost.png)](https://install-app.yunohost.org/?app=django-for-runners) -> *This package allows you to install Django-For-Runners quickly and simply on a YunoHost server. +> *This package allows you to install Django-For-Runners quickly and simply on a YunoHost server. If you don't have YunoHost, please consult [the guide](https://yunohost.org/#/install) to learn how to install it.* Current status is pre-alpha: This app doesn't work, yet ;) @@ -31,9 +31,16 @@ You can edit the file `$final_path/local_settings.py` to enable or disable featu # Miscellaneous -## LDAP connection -Supported by https://github.com/django-auth-ldap/django-auth-ldap +## SSO authentication + +[SSOwat](https://github.com/YunoHost/SSOwat) is fully supported: + +* First user (`$YNH_APP_ARG_ADMIN`) will be created as Django's super user +* All new users will be created as normal users +* Login via SSO is fully supported +* User Email, First / Last name will be updated from SSO data + ## Links @@ -131,4 +138,4 @@ Notes: * SQlite database will be used * A super user with username `test` and password `test` is created -* The page is available under `http://127.0.0.1:8000/app_path/` \ No newline at end of file +* The page is available under `http://127.0.0.1:8000/app_path/` diff --git a/conf/ynh_authenticate.py b/conf/ynh_authenticate.py new file mode 100644 index 0000000..1f3d03c --- /dev/null +++ b/conf/ynh_authenticate.py @@ -0,0 +1,185 @@ +""" + * remote user authentication backend + * remote user middleware + + Note: SSOwat/nginx add authentication headers: + + 'HTTP_AUTHORIZATION': 'Basic XXXXXXXXXXXXXXXX=' + 'HTTP_AUTH_USER': 'username' + 'HTTP_REMOTE_USER': 'username' + + Basic auth contains "{username}:{plaintext-password}" + + and we get SSOwat cookies like: + + 'HTTP_COOKIE': 'SSOwAuthUser=username; ' + 'SSOwAuthHash=593876aa66...99e69f88af1e; ' + 'SSOwAuthExpire=1609227697.998; ' + + * Login a user via HTTP_REMOTE_USER header, but check also username in: + * SSOwAuthUser + * HTTP_AUTH_USER + * HTTP_AUTHORIZATION (Basic auth) + * Create new users + * Update Email, First / Last name for existing users +""" +import base64 +import logging + +from axes.exceptions import AxesBackendPermissionDenied +from django.contrib.auth.backends import RemoteUserBackend as OriginRemoteUserBackend +from django.contrib.auth.middleware import RemoteUserMiddleware as OriginRemoteUserMiddleware +from django.core.exceptions import ValidationError +from inventory.permissions import get_or_create_normal_user_group + +logger = logging.getLogger(__name__) + + +def update_user_profile(request): + """ + Update existing user information: + * Email + * First / Last name + """ + user = request.user + assert user.is_authenticated + + update_fields = [] + + if not user.password: + # Empty password is not valid, so we can't save the model, because of full_clean() call + logger.info('Set unusable password for user: %s', user) + user.set_unusable_password() + update_fields.append('password') + + email = request.META.get('HTTP_EMAIL') + if email and user.email != email: + logger.info('Update email: %r -> %r', user.email, email) + user.email = email + update_fields.append('email') + + raw_username = request.META.get('HTTP_NAME') + if raw_username: + if ' ' in raw_username: + first_name, last_name = raw_username.split(' ', 1) + else: + first_name = '' + last_name = raw_username + + if user.first_name != first_name: + logger.info('Update first name: %r -> %r', user.first_name, first_name) + user.first_name = first_name + update_fields.append('first_name') + + if user.last_name != last_name: + logger.info('Update last name: %r -> %r', user.last_name, last_name) + user.last_name = last_name + update_fields.append('last_name') + + if update_fields: + try: + user.full_clean() + except ValidationError: + logger.exception('Can not update user: %s', user) + else: + user.save(update_fields=update_fields) + + +class RemoteUserMiddleware(OriginRemoteUserMiddleware): + """ + Middleware to login a user HTTP_REMOTE_USER header. + Use Django Axes if something is wrong. + Update exising user informations. + """ + header = 'HTTP_REMOTE_USER' + force_logout_if_no_header = True + + def process_request(self, request): + # Keep the information if the user is already logged in + was_authenticated = request.user.is_authenticated + + super().process_request(request) # login remote user + + if not request.user.is_authenticated: + # Not logged in -> nothing to verify here + return + + # Check SSOwat cookie informations: + try: + username = request.COOKIES['SSOwAuthUser'] + except KeyError: + logger.error('SSOwAuthUser cookie missing!') + + # emits a signal indicating user login failed, which is processed by + # axes.signals.log_user_login_failed which logs and flags the failed request. + raise AxesBackendPermissionDenied('Cookie missing') + + logger.info('SSOwat username from cookies: %r', username) + if username != request.user.username: + raise AxesBackendPermissionDenied('Wrong username') + + # Compare with HTTP_AUTH_USER + try: + username = request.META['HTTP_AUTH_USER'] + except KeyError: + logger.error('HTTP_AUTH_USER missing!') + raise AxesBackendPermissionDenied('No HTTP_AUTH_USER') + + if username != request.user.username: + raise AxesBackendPermissionDenied('Wrong HTTP_AUTH_USER username') + + # Also check 'HTTP_AUTHORIZATION', but only the username ;) + try: + auth = request.META['HTTP_AUTHORIZATION'] + except KeyError: + logger.error('HTTP_AUTHORIZATION missing!') + raise AxesBackendPermissionDenied('No HTTP_AUTHORIZATION') + + scheme, creds = auth.split(' ', 1) + if scheme.lower() != 'basic': + logger.error('HTTP_AUTHORIZATION with %r not supported', scheme) + raise AxesBackendPermissionDenied('HTTP_AUTHORIZATION scheme not supported') + + creds = str(base64.b64decode(creds), encoding='utf-8') + username = creds.split(':', 1)[0] + if username != request.user.username: + raise AxesBackendPermissionDenied('Wrong HTTP_AUTHORIZATION username') + + if not was_authenticated: + # First request, after login -> update user informations + logger.info('Remote used was logged in') + update_user_profile(request) + + +class RemoteUserBackend(OriginRemoteUserBackend): + """ + Authentication backend via SSO/nginx header + """ + create_unknown_user = True + + def authenticate(self, request, remote_user): + logger.info('Remote user authenticate: %r', remote_user) + return super().authenticate(request, remote_user) + + def configure_user(self, request, user): + """ + Configure a user after creation and return the updated user. + Setup a normal, non-superuser + """ + logger.warning('Configure user %s', user) + + user.set_unusable_password() # Always login via SSO + user.is_staff = True + user.is_superuser = False + user.save() + + pyinventory_user_group = get_or_create_normal_user_group()[0] + user.groups.set([pyinventory_user_group]) + + update_user_profile(request) + + return user + + def user_can_authenticate(self, user): + logger.warning('Remote user login: %s', user) + return True diff --git a/conf/ynh_for_runners_settings.py b/conf/ynh_for_runners_settings.py index 9eacb76..55864e6 100644 --- a/conf/ynh_for_runners_settings.py +++ b/conf/ynh_for_runners_settings.py @@ -11,8 +11,6 @@ from pathlib import Path as __Path -import ldap -from django_auth_ldap.config import LDAPSearch from for_runners_project.settings.base import * # noqa DEBUG = False @@ -36,44 +34,30 @@ PATH_URL = PATH_URL.strip('/') ROOT_URLCONF = 'ynh_urls' # /opt/yunohost/django-for-runners/ynh_urls.py # ----------------------------------------------------------------------------- -# https://github.com/django-auth-ldap/django-auth-ldap - -LDAP_SERVER_URI = 'ldap://localhost:389' -LDAP_START_TLS = True - -# enable anonymous searches -# https://django-auth-ldap.readthedocs.io/en/latest/authentication.html?highlight=anonymous#search-bind -LDAP_BIND_DN = '' -LDAP_BIND_PASSWORD = '' - -LDAP_ROOT_DN = 'ou=users,dc=yunohost,dc=org' - -AUTH_LDAP_USER_SEARCH = LDAPSearch(LDAP_ROOT_DN, ldap.SCOPE_SUBTREE, '(uid=%(user)s)') - -# Populate the Django user from the LDAP directory. -AUTH_LDAP_USER_ATTR_MAP = { - 'username': 'uid', - 'first_name': 'givenName', - 'last_name': 'sn', - 'email': 'mail', -} - -AUTH_LDAP_ALWAYS_UPDATE_USER = True - -# Don't use LDAP group membership to calculate group permissions -AUTH_LDAP_FIND_GROUP_PERMS = False - -# TODO: -# AUTH_LDAP_GROUP_TYPE = 'normal user' # Same as: inventory.permissions.NORMAL_USER_GROUP_NAME - -# Cache distinguished names and group memberships for an hour to minimize LDAP traffic -AUTH_LDAP_CACHE_TIMEOUT = 3600 # Keep ModelBackend around for per-user permissions and superuser AUTHENTICATION_BACKENDS = ( - 'django_auth_ldap.backend.LDAPBackend', + 'axes.backends.AxesBackend', # AxesBackend should be the first backend! + + # Authenticate via SSO and nginx 'HTTP_REMOTE_USER' header: + 'ynh_authenticate.RemoteUserBackend', + + # Fallback to normal Django model backend: 'django.contrib.auth.backends.ModelBackend', ) +LOGIN_REDIRECT_URL = None +LOGIN_URL = '/yunohost/sso/' +LOGOUT_REDIRECT_URL = '/yunohost/sso/' +# /yunohost/sso/?action=logout + +# ----------------------------------------------------------------------------- +# https://docs.djangoproject.com/en/2.2/howto/auth-remote-user/ +# Add RemoteUserMiddleware after AuthenticationMiddleware + +MIDDLEWARE.insert( + MIDDLEWARE.index('django.contrib.auth.middleware.AuthenticationMiddleware') + 1, + 'ynh_authenticate.RemoteUserMiddleware', +) # ----------------------------------------------------------------------------- @@ -165,7 +149,7 @@ LOGGING = { 'class': 'django.utils.log.AdminEmailHandler', 'include_html': True, }, - 'syslog': { + 'log_file': { 'level': 'DEBUG', 'class': 'logging.handlers.WatchedFileHandler', 'formatter': 'verbose', @@ -173,12 +157,11 @@ LOGGING = { }, }, 'loggers': { - '': {'handlers': ['syslog', 'mail_admins'], 'level': 'INFO', 'propagate': False}, - 'django': {'handlers': ['syslog', 'mail_admins'], 'level': 'INFO', 'propagate': False}, - 'axes': {'handlers': ['syslog', 'mail_admins'], 'level': 'WARNING', 'propagate': False}, - 'django_tools': {'handlers': ['syslog', 'mail_admins'], 'level': 'INFO', 'propagate': False}, - 'django_auth_ldap': {'handlers': ['syslog', 'mail_admins'], 'level': 'DEBUG', 'propagate': False}, - 'inventory': {'handlers': ['syslog', 'mail_admins'], 'level': 'INFO', 'propagate': False}, + '': {'handlers': ['log_file', 'mail_admins'], 'level': 'INFO', 'propagate': False}, + 'django': {'handlers': ['log_file', 'mail_admins'], 'level': 'INFO', 'propagate': False}, + 'axes': {'handlers': ['log_file', 'mail_admins'], 'level': 'WARNING', 'propagate': False}, + 'django_tools': {'handlers': ['log_file', 'mail_admins'], 'level': 'INFO', 'propagate': False}, + 'for_runners': {'handlers': ['log_file', 'mail_admins'], 'level': 'INFO', 'propagate': False}, }, } diff --git a/scripts/_common.sh b/scripts/_common.sh index 93e5c97..013a0bc 100644 --- a/scripts/_common.sh +++ b/scripts/_common.sh @@ -25,14 +25,14 @@ log_file="${log_path}/django-for-runners.log" #================================================= # dependencies used by the app -pkg_dependencies="build-essential python3-dev python3-pip python3-virtualenv git \ - postgresql postgresql-contrib python3-ldap libldap2-dev libsasl2-dev" +pkg_dependencies="build-essential python3-dev python3-pip python3-venv git \ + postgresql postgresql-contrib" # Django-For-Runners's version for PIP and settings file for_runners_version="0.12.0rc2" # Extra python packages: -pypi_extras="django-redis django-auth-ldap" +pypi_extras="django-redis" #================================================= # Redis HELPERS diff --git a/scripts/install b/scripts/install index fbc7b25..5a7ff0b 100755 --- a/scripts/install +++ b/scripts/install @@ -159,6 +159,7 @@ ynh_app_setting_set --app="$app" --key=redis_db --value="$redis_db" touch "$final_path/local_settings.py" +cp "../conf/ynh_authenticate.py" "$final_path/ynh_authenticate.py" cp "../conf/ynh_urls.py" "$final_path/ynh_urls.py" #================================================= diff --git a/scripts/upgrade b/scripts/upgrade index 78cccce..b9e5262 100755 --- a/scripts/upgrade +++ b/scripts/upgrade @@ -149,6 +149,7 @@ ynh_store_file_checksum --file="$settings" touch "$final_path/local_settings.py" +cp "../conf/ynh_authenticate.py" "$final_path/ynh_authenticate.py" cp "../conf/ynh_urls.py" "$final_path/ynh_urls.py" #=================================================