1
0
Fork 0
mirror of https://github.com/YunoHost-Apps/pyinventory_ynh.git synced 2024-09-03 20:16:09 +02:00

Authenticate via SSO and nginx 'HTTP_REMOTE_USER' header

This commit is contained in:
JensDiemer 2020-12-20 19:42:08 +01:00
parent 473d508cc9
commit 3b8b01f73f
12 changed files with 281 additions and 107 deletions

View file

@ -1,6 +1,6 @@
# PyInventory for YunoHost
[![Integration level](https://dash.yunohost.org/integration/pyinventory.svg)](https://dash.yunohost.org/appci/app/pyinventory) ![](https://ci-apps.yunohost.org/ci/badges/pyinventory.status.svg) ![](https://ci-apps.yunohost.org/ci/badges/pyinventory.maintain.svg)
[![Integration level](https://dash.yunohost.org/integration/pyinventory.svg)](https://dash.yunohost.org/appci/app/pyinventory) ![](https://ci-apps.yunohost.org/ci/badges/pyinventory.status.svg) ![](https://ci-apps.yunohost.org/ci/badges/pyinventory.maintain.svg)
[![Install PyInventory with YunoHost](https://install-app.yunohost.org/install-with-yunohost.svg)](https://install-app.yunohost.org/?app=pyinventory)
> *This package allows you to install PyInventory quickly and simply on a YunoHost server.
@ -26,9 +26,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

View file

@ -24,12 +24,15 @@ def main():
from django.contrib.auth import get_user_model
User = get_user_model()
super_user = User.objects.filter(username=username).first()
if super_user:
print('Update existing super user and set his password.', file=sys.stderr)
super_user.set_password(password)
super_user.email=email
super_user.save()
user = User.objects.filter(username=username).first()
if user:
print('Update existing user and set his password.', file=sys.stderr)
user.is_active = True
user.is_staff = True
user.is_superuser = True
user.set_password(password)
user.email = email
user.save()
else:
print('Create new super user', file=sys.stderr)
User.objects.create_superuser(

View file

@ -1,8 +1,8 @@
location __PATH__/static/ {
# Django static files
alias __PUBLIC_PATH__/static/;
expires 30d;
# Django static files
alias __PUBLIC_PATH__/static/;
expires 30d;
}
# TODO: django-sendfile2:
@ -13,25 +13,22 @@ location __PATH__/static/ {
#}
location / {
# https://github.com/benoitc/gunicorn/blob/master/examples/nginx.conf
# https://github.com/benoitc/gunicorn/blob/master/examples/nginx.conf
# this is needed if you have file import via upload enabled
client_max_body_size 100M;
# this is needed if you have file import via upload enabled
client_max_body_size 100M;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Protocol $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
proxy_read_timeout 30;
proxy_send_timeout 30;
proxy_connect_timeout 30;
proxy_redirect off;
proxy_read_timeout 30;
proxy_send_timeout 30;
proxy_connect_timeout 30;
proxy_redirect off;
proxy_pass http://127.0.0.1:__PORT__/;
# Include SSOWAT user panel.
#include conf.d/yunohost_panel.conf.inc;
proxy_pass http://127.0.0.1:__PORT__/;
}

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 inventory_project.settings.base import * # noqa
DEBUG = False
@ -36,43 +34,30 @@ PATH_URL = PATH_URL.strip('/')
ROOT_URLCONF = 'ynh_urls' # /opt/yunohost/pyinventory/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
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',
)
# -----------------------------------------------------------------------------
@ -153,7 +138,7 @@ CKEDITOR_BASEPATH = STATIC_URL + 'ckeditor/ckeditor/'
# _____________________________________________________________________________
# Django-dbbackup
DBBACKUP_STORAGE_OPTIONS['location']=str(FINAL_HOME_PATH / 'backups')
DBBACKUP_STORAGE_OPTIONS['location'] = str(FINAL_HOME_PATH / 'backups')
# -----------------------------------------------------------------------------
@ -173,7 +158,7 @@ LOGGING = {
'class': 'django.utils.log.AdminEmailHandler',
'include_html': True,
},
'syslog': {
'log_file': {
'level': 'DEBUG',
'class': 'logging.handlers.WatchedFileHandler',
'formatter': 'verbose',
@ -181,12 +166,12 @@ 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},
'django_auth_ldap': {'handlers': ['log_file', 'mail_admins'], 'level': 'DEBUG', 'propagate': False},
'inventory': {'handlers': ['log_file', 'mail_admins'], 'level': 'INFO', 'propagate': False},
},
}

View file

@ -1,17 +0,0 @@
from django_auth_ldap.backend import LDAPBackend
from inventory.permissions import get_or_create_normal_user_group
class PyInventoryYunohostLdapBackend(LDAPBackend):
def get_or_build_user(self, username, ldap_user):
user, built = super().get_or_build_user(username, ldap_user)
if built:
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])
return user, built

View file

@ -4,12 +4,27 @@ from django.contrib import admin
from django.urls import path
from django.views.generic import RedirectView
# def debug_view(request):
# """ debug request.META """
# if not request.user.is_authenticated:
# from django.shortcuts import redirect
# return redirect('admin:index')
#
# import pprint
# meta = pprint.pformat(request.META)
# html = f'<html><body>request.META: <pre>{meta}</pre></body></html>'
# from django.http import HttpResponse
# return HttpResponse(html)
# settings.PATH_URL is the $YNH_APP_ARG_PATH
if settings.PATH_URL:
# Prefix all urls with "PATH_URL":
urlpatterns = [
path(f'{settings.PATH_URL}/admin/', admin.site.urls),
# path(f'{settings.PATH_URL}/', debug_view),
path(f'{settings.PATH_URL}/', RedirectView.as_view(pattern_name='admin:index')),
path(f'{settings.PATH_URL}/ckeditor/', include('ckeditor_uploader.urls')),

View file

@ -5,7 +5,7 @@
"description": {
"en": "Web based management to catalog things including state and location etc."
},
"version": "0.8.2~ynh1",
"version": "0.8.2~ynh2",
"url": "https://github.com/jedie/PyInventory",
"license": "GPL-3.0",
"maintainer": {

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "pyinventory_ynh"
version = "0.8.1~ynh7"
version = "0.8.2~ynh2"
description = "Test pyinventory_ynh via local_test.py"
authors = ["JensDiemer <git@jensdiemer.de>"]
license = "GPL"
@ -8,7 +8,6 @@ license = "GPL"
[tool.poetry.dependencies]
python = ">=3.7,<4.0.0"
pyinventory = "*"
django-auth-ldap = "*"
[tool.poetry.dev-dependencies]

View file

@ -25,13 +25,13 @@ log_file="${log_path}/pyinventory.log"
# dependencies used by the app
pkg_dependencies="build-essential python3-dev python3-pip python3-venv git \
postgresql postgresql-contrib python3-ldap libldap2-dev libsasl2-dev"
postgresql postgresql-contrib"
# PyInventory's version for PIP and settings file
pyinventory_version="0.8.2"
# Extra python packages:
pypi_extras="django-redis django-auth-ldap"
pypi_extras="django-redis"
#=================================================
# Redis HELPERS

View file

@ -157,7 +157,7 @@ ynh_app_setting_set --app="$app" --key=redis_db --value="$redis_db"
touch "$final_path/local_settings.py"
cp "../conf/ynh_sso_ldap_backend.py" "$final_path/ynh_sso_ldap_backend.py"
cp "../conf/ynh_authenticate.py" "$final_path/ynh_authenticate.py"
cp "../conf/ynh_urls.py" "$final_path/ynh_urls.py"
#=================================================
@ -166,21 +166,21 @@ cp "../conf/ynh_urls.py" "$final_path/ynh_urls.py"
ynh_script_progression --message="migrate/collectstatic/createadmin..." --weight=10
(
set +o nounset
source "${final_path}/venv/bin/activate"
set -o nounset
cd "${final_path}"
set +o nounset
source "${final_path}/venv/bin/activate"
set -o nounset
cd "${final_path}"
# Just for debugging:
./manage.py diffsettings
# Just for debugging:
./manage.py diffsettings
./manage.py migrate --no-input
./manage.py collectstatic --no-input
./create_superuser.py --username="$admin" --email="$admin_mail" --password="pyinventory"
./manage.py migrate --no-input
./manage.py collectstatic --no-input
./create_superuser.py --username="$admin" --email="$admin_mail" --password="pyinventory"
# Check the configuration
# This may fail in some cases with errors, etc., but the app works and the user can fix issues later.
./manage.py check --deploy || true
# Check the configuration
# This may fail in some cases with errors, etc., but the app works and the user can fix issues later.
./manage.py check --deploy || true
)
#=================================================

View file

@ -147,7 +147,7 @@ ynh_store_file_checksum --file="$settings"
touch "$final_path/local_settings.py"
cp "../conf/ynh_sso_ldap_backend.py" "$final_path/ynh_sso_ldap_backend.py"
cp "../conf/ynh_authenticate.py" "$final_path/ynh_authenticate.py"
cp "../conf/ynh_urls.py" "$final_path/ynh_urls.py"
#=================================================