Autenticate completely with SSOwat

This commit is contained in:
JensDiemer 2020-12-22 18:55:57 +01:00
parent 85b573046b
commit 7d225c5663
6 changed files with 227 additions and 50 deletions

View file

@ -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/`
* The page is available under `http://127.0.0.1:8000/app_path/`

185
conf/ynh_authenticate.py Normal file
View file

@ -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

View file

@ -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},
},
}

View file

@ -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

View file

@ -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"
#=================================================

View file

@ -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"
#=================================================