Struktur og dataflyt

Katalogstruktur

Programkoden ligger i katalogen src/ i repoet. Der ligger tre underkataloger:

  • discourse_sso/: Hovedkatalog for Django-prosjektet, inneholder konfigurasjon (settings.py) og URL dispatcher (urls.py). Alle innstillinger settes normalt i fila .env på toppnivået, og ikke her.

  • home/: Minimal app for å vise startsiden (login/logout-funksjon + lenke til admin-grensesnitt for superbruker).

  • sso/: Single Sign-On-applikasjonen.

Dataflyt

En Single-Sign-On-pålogging skjer slik:

  1. Brukeren åpner Discourse i nettleseren og klikker “Log in”-knappen i Discourse.

  2. Discourse redirigerer brukeren til et endepunkt i sso-applikasjonen. URL-en brukeren redirigeres til inneholder Discourse Site ID (slik at vi vet hvilken Discourse-installasjon vi skal redirigere brukeren tilbake etter autentisering), og Discourse sender med noe ekstra informasjon i query string:

    Eksempel: https://discourse-sso.math.ntnu.no/sso/42?sso=<payload>&sig=<signature>

    Innholdet i query string er en HMAC-beskyttet payload som inneholder et engangstall (nonce) som identifiserer påloggingsforsøket.

    Se dokumentasjonen av DiscourseConnect-protokollen for detaljer.

  3. Viewet bak endepunktet (sso.views.sso i src/sso/views.py) er beskyttet av dekoratoren @login_required, og sender brukeren på en autentiseringsrunde til Feide (med mindre brukeren allerede har autentisert seg og har en gyldig sesjon).

  4. Feide autentiserer brukeren og redirigerer nettleseren tilbake til endepunktet vårt.

  5. Ved vellykket pålogging kalles da i utgangspunktet sso.views.sso(), men før dette skjer, gjør Django-middlewaren dataporten.middleware.DataportenGroupsMiddleware et oppslag mot Feide Groups API og henter ut gruppeinformasjon om brukeren. Denne middlewaren hekter på et nytt attributt dataporten på brukerobjektet request.user som vi kan bruke til å hente ut resultatet av dette oppslaget.

  6. Koden i sso.views.sso() gjør så sitt:

    6.1. Først sjekker vi hvilken autentiseringskilde brukeren har benyttet. django-allauth støtter mange ulike mekanismer (inkl. Twitter, Facebook, etc.), og det ser ikke ut til å være noen dokumentert måte å skru av de uønskede mekanismene på. I stedet for å konfigurere django-allauth til å kun bruke Feide (Dataporten) som backend, sjekker vi eksplisitt om brukeren har blitt logget inn via Feide, og avbryter med HTTP 404 i motsatt fall.

    6.2. Deretter henter vi ut brukerens navn, epostadresse og Feide-brukernavn fra informasjonen vi fikk direkte fra Feide. Dette brukes til å tildele brukernavn i Discourse. Alle NTNU-brukere får beholde kort-brukernavnet sitt, mens brukere fra andre institusjoner blir tildelt brukernavn_domene. (Discourse støtter ikke “@” i brukernavn, så vi kan ikke bruke Feide-brukernavnet direkte.)

    6.3. Deretter tildeles gruppemedlemskap og evt. moderator/admin-rettigheter ut fra gruppetilhørighetene django-dataporten har slått opp og lagret i attributtet user.dataporten, samt medlemskapene som har blitt tilordnet via lokale Django-grupper. Se Gruppemedlemskap og hjelpefunksjonene groups(), is_moderator() og is_admin() i src/sso/functions.py.

    6.4. All informasjonen over pakkes sammen i en respons til Discourse som også inneholder engangstallet fra den opprinnelige autentiseringsrequesten. Responsen signeres med samme HMAC-nøkkel som opprinnelig request, og oversendes ved at brukeren redirigeres enda en gang, denne gangen tilbake til Discourse, men med vår respons som query string.

  7. Discourse sjekker om HMAC-signaturen i responsen stemmer, og logger deretter brukeren inn (oppretter brukeren hvis den ikke allerede finnes, setter/endrer epostadresse, tildeler gruppemedlemskap, og så videre).

Gruppemodell

Vi bruker vår egen gruppemodell i stedet for Djangos innebygde django.contrib.auth.models.Group, fordi vi ønsker muligheten til å:

  • La en gruppe inneholde både brukere og andre grupper.

  • Kunne delegere gruppeadministrasjon (gruppen skal kunne administreres av en eller flere brukere eller grupper).

Klassen heter sso.models.DiscourseGroup og har disse attributtene:

  • name: Gruppens navn (tekststreng).

  • members: Brukere som er medlemmer av gruppen (mange-til-mange-forhold til brukere).

  • child_groups: Grupper som er medlemmer av gruppen (mange-til-mange-forhold til andre grupper).

  • admins: Brukere som kan administrere denne gruppen (mange-til-mange-forhold til brukere).

  • admin_groups: Grupper som kan administrere denne gruppen (mange-til-mange-forhold til andre grupper).

For å forenkle oppslag av gruppemedlemskap/søk i grupper som kan administreres, har vi erstattet klassens Manager med vår egen sso.models.DiscourseGroupManager, som har noen ekstra metoder:

  • user_is_member(user): Regner ut gruppemedlemskap rekursivt og returnerer et QuerySet som inneholder alle gruppene brukeren er enten direkte eller indirekte medlem av.

  • user_is_admin(user): Returnerer et QuerySet som inneholder alle gruppene brukeren har rettigheter til å administrere.

Disse brukes typisk slik:

groups = DiscourseGroup.objects.user_is_member(request.user)

Det er dessverre ingen attributter på brukerobjektet request.user som kan følge disse relasjonene baklengs og returnere riktig sett av grupper.

(Vi kan ikke hekte hjelpemetoder på User-objektet for å returnere brukerens grupper, siden vi er nødt til å bruke Djangos innebygde django.contrib.auth.models.User-modell. Framgangsmåten på Substituting a custom User model i Django-dokumentasjonen fungerer ikke fordi en av Python-pakkene vi bruker har hardkodet en antagelse om at brukerobjektet er en django.contrib.auth.models.User.)