diff --git a/.gitignore b/.gitignore index 78241ff..666371a 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,4 @@ ENV/ # mypy .mypy_cache/ +rhosts_dev diff --git a/README.md b/README.md index 43da027..76f5514 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ nécessaires en base de données : ```bash cd .. # Pour revenir dans bocal-site ./manage.py migrate +./manage.py loaddata bocal_group ``` À partir de là, ça tourne :) Il ne reste plus qu'à faire tourner soit avec un diff --git a/bocal/settings_dev.py b/bocal/settings_dev.py index 94e76f0..0045043 100644 --- a/bocal/settings_dev.py +++ b/bocal/settings_dev.py @@ -43,3 +43,6 @@ CAS_FORCE_CHANGE_USERNAME_CASE = 'lower' CAS_REDIRECT_URL = '/' CAS_EMAIL_FORMAT = "%s@clipper.ens.fr" CAS_LOGOUT_COMPLETELY = False + +# Auth +RHOSTS_PATH = 'rhosts_dev' diff --git a/bocal/settings_prod.py b/bocal/settings_prod.py index 766b6ac..2aad1d8 100644 --- a/bocal/settings_prod.py +++ b/bocal/settings_prod.py @@ -54,3 +54,6 @@ CAS_FORCE_CHANGE_USERNAME_CASE = 'lower' CAS_REDIRECT_URL = '/' CAS_EMAIL_FORMAT = "%s@clipper.ens.fr" # FIXME CAS_LOGOUT_COMPLETELY = False + +# Auth +RHOSTS_PATH = '' # FIXME (path to BOcal's .rhosts) diff --git a/bocal/urls.py b/bocal/urls.py index e423159..56955aa 100644 --- a/bocal/urls.py +++ b/bocal/urls.py @@ -20,6 +20,7 @@ import django.contrib.auth.views as dj_auth_views import mainsite.urls import bocal_auth.views as auth_views +from bocal_auth.rhosts import forceReevalRhosts import markdownx.urls import api.urls @@ -27,7 +28,7 @@ import django_cas_ng.views # Force the user to login through the custom login page -admin.site.login = login_required(admin.site.login) +admin.site.login = login_required(forceReevalRhosts(admin.site.login)) cas_patterns = [ url(r'^login$', django_cas_ng.views.login, name='cas_ng_login'), diff --git a/bocal_auth/__init__.py b/bocal_auth/__init__.py index e69de29..917eea3 100644 --- a/bocal_auth/__init__.py +++ b/bocal_auth/__init__.py @@ -0,0 +1 @@ +default_app_config = 'bocal_auth.apps.BocalAuthConfig' diff --git a/bocal_auth/apps.py b/bocal_auth/apps.py index 08a0faa..ea9408d 100644 --- a/bocal_auth/apps.py +++ b/bocal_auth/apps.py @@ -1,5 +1,8 @@ from django.apps import AppConfig -class AuthConfig(AppConfig): +class BocalAuthConfig(AppConfig): name = 'bocal_auth' + + def ready(self): + from . import signals diff --git a/bocal_auth/cas_backend.py b/bocal_auth/cas_backend.py index a9abe05..ed50d2b 100644 --- a/bocal_auth/cas_backend.py +++ b/bocal_auth/cas_backend.py @@ -1,7 +1,13 @@ from django_cas_ng.backends import CASBackend +from .models import CasUser +from . import rhosts class BOcalCASBackend(CASBackend): # Partly from Robin Champenois's "ExperiENS". Thanks! def clean_username(self, username): return username.lower().strip() + + def configure_user(self, user): + casUser = CasUser(user=user) + casUser.save() diff --git a/bocal_auth/fixtures/bocal_group.json b/bocal_auth/fixtures/bocal_group.json new file mode 100644 index 0000000..b6638e7 --- /dev/null +++ b/bocal_auth/fixtures/bocal_group.json @@ -0,0 +1 @@ +[{"model": "auth.group", "pk": 1, "fields": {"name": "BOcal", "permissions": [28, 29, 30, 19, 20, 21, 22, 23, 24, 25, 26, 27]}}] diff --git a/bocal_auth/migrations/0001_initial.py b/bocal_auth/migrations/0001_initial.py new file mode 100644 index 0000000..9bca4fd --- /dev/null +++ b/bocal_auth/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2017-10-14 16:49 +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='CasUser', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/bocal_auth/models.py b/bocal_auth/models.py new file mode 100644 index 0000000..c83b39a --- /dev/null +++ b/bocal_auth/models.py @@ -0,0 +1,11 @@ +from django.db import models +from django.contrib.auth.models import User + + +class CasUser(models.Model): + ''' Describes a Django user that was created through CAS ''' + + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + primary_key=True) diff --git a/bocal_auth/rhosts.py b/bocal_auth/rhosts.py new file mode 100644 index 0000000..2a17323 --- /dev/null +++ b/bocal_auth/rhosts.py @@ -0,0 +1,99 @@ +''' Reads a .rhosts file ''' + +from django.conf import settings +from django.contrib.auth.models import Group +from .models import CasUser + + +def hasUser(user, allowed_domains=[]): + ''' Check that `user` appears in the rhosts file. + If `allowed_domains` is not empty, also checks that the user belongs to one + of the specified domains. ''' + + def clearLine(line): + line = line.strip() + hashPos = line.find('#') + if hashPos >= 0: + line = line[:hashPos] + return line + + with open(settings.RHOSTS_PATH, 'r') as handle: + for line in handle: + line = clearLine(line) + if not line: + continue + + spl = line.split() + if len(spl) != 2: + continue # Not a login line + + domain, login = spl + if login != user: # Not the ones we're looking for + continue + + if domain[:2] != '+@': # Not a valid domain + continue + domain = domain[2:] + + if allowed_domains != [] and domain not in allowed_domains: + continue + + return True + return False + + +def default_allowed(user): + return hasUser(user, allowed_domains=['eleves']) + + +class NoBOcalException(Exception): + def __str__(): + return "The BOcal group was not created" + + +def bocalGroup(): + qs = Group.objects.filter(name='BOcal') + if qs.count() != 1: + raise NoBOcalException + return qs[0] + + +def stripCasPrivileges(user): + user.groups.remove(bocalGroup()) + user.is_staff = False + user.save() + + +def grantBOcalPrivileges(user): + user.is_staff = True + user.groups.add(bocalGroup()) + user.save() + + +def requireCasUser(fct): + def wrap(user, *args, **kwargs): + qs = CasUser.objects.filter(user=user) + if not qs.count() > 0: + return + return fct(user, *args, **kwargs) + return wrap + + +@requireCasUser +def evalRhostsPrivileges(user): + if default_allowed(user.username): + grantBOcalPrivileges(user) + else: + stripCasPrivileges(user) + + +@requireCasUser +def logout(user): + stripCasPrivileges() + + +def forceReevalRhosts(fct): + def wrap(req, *args, **kwargs): + evalRhostsPrivileges(req.user) + return fct(req, *args, **kwargs) + return wrap diff --git a/bocal_auth/signals.py b/bocal_auth/signals.py new file mode 100644 index 0000000..bbba5a8 --- /dev/null +++ b/bocal_auth/signals.py @@ -0,0 +1,16 @@ +from django.dispatch import receiver +from django_cas_ng.signals import cas_user_authenticated, cas_user_logout +from . import rhosts + + +@receiver(cas_user_authenticated) +def onCasLogin(sender, user, **kwargs): + ''' Called upon login of a user through CAS ''' + rhosts.evalRhostsPrivileges(user) + + +@receiver(cas_user_logout) +def onCasLogout(sender, user, **kwargs): + ''' Strip the user from their privileges — in case something goes wrong + during the next authentication ''' + rhosts.logout(user)