Skip to content
Snippets Groups Projects
Commit d7485898 authored by Sivert Kronen Hatteberg's avatar Sivert Kronen Hatteberg
Browse files

Merge branch 'GREG-36-feide' into 'master'

Greg 36 feide

See merge request !75
parents 4a69a757 1dc75ca7
No related branches found
No related tags found
1 merge request!75Greg 36 feide
Pipeline #94979 passed
...@@ -15,7 +15,7 @@ pip = python -m $(PIP) ...@@ -15,7 +15,7 @@ pip = python -m $(PIP)
poetry = python -m $(POETRY) poetry = python -m $(POETRY)
venv = . $(VENV)/bin/activate && venv = . $(VENV)/bin/activate &&
PACKAGES = greg/ gregsite/ PACKAGES = greg/ gregsite/ gregui/
all: test all: test
......
...@@ -45,6 +45,7 @@ INSTALLED_APPS = [ ...@@ -45,6 +45,7 @@ INSTALLED_APPS = [
"drf_spectacular", "drf_spectacular",
"django_extensions", "django_extensions",
"django_filters", "django_filters",
"mozilla_django_oidc",
"greg", "greg",
"gregui", "gregui",
] ]
...@@ -59,11 +60,14 @@ MIDDLEWARE = [ ...@@ -59,11 +60,14 @@ MIDDLEWARE = [
"sesame.middleware.AuthenticationMiddleware", "sesame.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
# Refresh oicd token
"mozilla_django_oidc.middleware.SessionRefresh",
"gregsite.middleware.revision_user_middleware.RevisionUserMiddleware", "gregsite.middleware.revision_user_middleware.RevisionUserMiddleware",
] ]
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", # default "django.contrib.auth.backends.ModelBackend", # default
"gregui.authentication.auth_backends.GregOIDCBackend",
"sesame.backends.ModelBackend", # link login "sesame.backends.ModelBackend", # link login
] ]
...@@ -73,8 +77,12 @@ SESSION_COOKIE_AGE = 1800 # lifetime of session in seconds ...@@ -73,8 +77,12 @@ SESSION_COOKIE_AGE = 1800 # lifetime of session in seconds
CSRF_COOKIE_SAMESITE = "Strict" CSRF_COOKIE_SAMESITE = "Strict"
SESSION_COOKIE_SAMESITE = "Strict" SESSION_COOKIE_SAMESITE = "Strict"
CSRF_COOKIE_HTTPONLY = True # CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_HTTPONLY = True # SESSION_COOKIE_HTTPONLY = True
# Enable these in production
# CSRF_COOKIE_SECURE = True
# SESSION_COOKIE_SECURE = True
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning",
...@@ -145,6 +153,28 @@ AUTH_PASSWORD_VALIDATORS = [ ...@@ -145,6 +153,28 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
] ]
# Override these in dev.py
OIDC_RP_CLIENT_ID = ""
OIDC_RP_CLIENT_SECRET = ""
OIDC_RP_SIGN_ALGO = "RS256"
OIDC_RP_SCOPES = "email openid userid userid-feide profile iss "
OIDC_OP_JWKS_ENDPOINT = "https://auth.dataporten.no/openid/jwks"
OIDC_OP_AUTHORIZATION_ENDPOINT = "https://auth.dataporten.no/oauth/authorization"
OIDC_OP_TOKEN_ENDPOINT = "https://auth.dataporten.no/oauth/token"
OIDC_OP_USER_ENDPOINT = "https://auth.dataporten.no/openid/userinfo"
ALLOW_LOGOUT_GET_METHOD = True
OIDC_END_SESSION_ENDPOINT = "https://auth.dataporten.no/openid/endsession"
OIDC_OP_LOGOUT_URL_METHOD = "gregui.authentication.auth_backends.provider_logout"
# Change these later
LOGIN_REDIRECT_URL = "http://localhost:3000/"
LOGOUT_REDIRECT_URL = "http://localhost:3000/"
# Extra config needed since mozilla-django-oidc does not comply with point 2. and 3.
# https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
OIDC_OP_ISSUER = "https://auth.dataporten.no"
OIDC_TRUSTED_AUDIENCES = []
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/ # https://docs.djangoproject.com/en/3.2/topics/i18n/
......
...@@ -13,3 +13,18 @@ ORGREG_CLIENT = { ...@@ -13,3 +13,18 @@ ORGREG_CLIENT = {
"endpoints": {"base_url": "https://example.com/fake/"}, "endpoints": {"base_url": "https://example.com/fake/"},
"headers": {"X-Gravitee-Api-Key": "bar"}, "headers": {"X-Gravitee-Api-Key": "bar"},
} }
AUTHENTICATION_BACKENDS = [
"gregui.authentication.auth_backends.DevBackend", # Fake dev backend
"django.contrib.auth.backends.ModelBackend", # default
"gregui.authentication.auth_backends.GregOIDCBackend",
"sesame.backends.ModelBackend", # link login
]
LOGIN_REDIRECT_URL = "http://localhost:3000/"
LOGOUT_REDIRECT_URL = "http://localhost:3000/"
CSRF_COOKIE_SAMESITE = "Strict"
SESSION_COOKIE_SAMESITE = "Lax"
# CSRF_COOKIE_HTTPONLY = True
# SESSION_COOKIE_HTTPONLY = True
...@@ -22,7 +22,8 @@ from gregui import urls as ui_urls ...@@ -22,7 +22,8 @@ from gregui import urls as ui_urls
admin.autodiscover() admin.autodiscover()
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("api/admin/", admin.site.urls),
path("", include(greg_urls.urlpatterns)), path("", include(greg_urls.urlpatterns)),
path("", include(ui_urls.urlpatterns)), path("", include(ui_urls.urlpatterns)),
path("api/oidc/", include("mozilla_django_oidc.urls")),
] ]
from django.contrib import admin from django.contrib import admin
from reversion.admin import VersionAdmin
# Register your models here. from gregui.models import GregUserProfile
class GregUserProfileAdmin(VersionAdmin):
pass
admin.site.register(GregUserProfile, GregUserProfileAdmin)
...@@ -4,7 +4,8 @@ from rest_framework.generics import CreateAPIView ...@@ -4,7 +4,8 @@ from rest_framework.generics import CreateAPIView
from greg.models import Person from greg.models import Person
from gregui.api.serializers.guest import GuestRegisterSerializer from gregui.api.serializers.guest import GuestRegisterSerializer
class GuestRegisterView(CreateAPIView): class GuestRegisterView(CreateAPIView):
queryset = Person.objects.all() queryset = Person.objects.all()
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
serializer_class = GuestRegisterSerializer serializer_class = GuestRegisterSerializer
\ No newline at end of file
from typing import (
Sequence,
Type,
)
from rest_framework import permissions
from rest_framework.authentication import BaseAuthentication, SessionAuthentication
from rest_framework.permissions import BasePermission
from rest_framework.views import APIView
from rest_framework.response import Response
from gregui.models import GregUserProfile
class UserInfoView(APIView):
"""
User info view.
Return info about the logged inn user.
Quick draft, we might want to expand this later.
"""
authentication_classes: Sequence[Type[BaseAuthentication]] = [SessionAuthentication]
permission_classes: Sequence[Type[BasePermission]] = [permissions.IsAuthenticated]
def get(self, request, format=None):
user = request.user
user_profile = GregUserProfile.objects.get(user=user)
sponsor_id = None
person_id = None
if user_profile.sponsor:
sponsor_id = user_profile.sponsor.id
if user_profile.person:
person_id = user_profile.person.id
content = {
"feide_id": user_profile.userid_feide,
"name": f"{user.first_name} {user.last_name}",
"sponsor_id": sponsor_id,
"person_id": person_id,
}
return Response(content)
import logging
import re
import time
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import BaseBackend
from django.core.exceptions import SuspiciousOperation
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
from greg.models import Identity, Person, Sponsor
from gregui.models import GregUserProfile
logger = logging.getLogger(__name__)
class DevBackend(BaseBackend):
"""
Development backend, no password checking.
TODO:
- Expand to add person/sponsor for the user
- How do we do login? A dev login site?
"""
def __init__(self):
self.UserModel = get_user_model()
dev_users = {
"bruker1": {
"email": "bruker1@uio.no",
"first_name": "test",
"last_name": "testesen",
},
"bruker2": {
"email": "bruker2@uio.no",
"first_name": "foo",
"last_name": "foosen",
},
}
def get_userinfo(self, username):
return self.dev_users.get(username, {})
def get_or_create_user(self, username):
if not username:
return None
try:
user = self.UserModel.objects.get(username=username)
except self.UserModel.DoesNotExist:
userinfo = self.get_userinfo(username)
user = self.UserModel(username=username, **userinfo)
user.save()
return user
def authenticate(self, request, **kwargs):
# This has been made so that developers can authenticate as any user in test
username = kwargs.get("username")
logger.debug("Username is %s", username)
request.session["oidc_id_token_payload"] = {"iat": time.time()}
return self.get_or_create_user(username)
def get_user(self, user_id):
try:
return self.UserModel.objects.get(pk=user_id)
except self.UserModel.DoesNotExist:
return None
# This regex is quite naive since it assumes that the person only has one last_name,
# and everything before that is the first_name.
NAMES_REGEX = re.compile(r"^(?P<first_name>.+) (?P<last_name>.+)$")
ID_REGEX = re.compile(r"^(?P<id_type>[^:]+):(?P<id_value>.+)$")
def extract_userinfo(claims):
name = claims["name"]
match = re.search(NAMES_REGEX, name)
first_name = match.group("first_name")
last_name = match.group("last_name")
userinfo = {
"email": claims["email"],
"first_name": first_name,
"last_name": last_name,
}
secondary_user_ids = claims["connect-userid_sec"]
for user_id in secondary_user_ids:
match = re.search(ID_REGEX, user_id)
id_type = match.group("id_type")
id_value = match.group("id_value")
if id_type == "feide":
userinfo["userid_feide"] = id_value
# TODO:
# Should we also save nin of the user?
# What should we do if a user has multiple feide_ids?
return userinfo
class ValidatingOIDCBackend(OIDCAuthenticationBackend):
def validate_issuer(self, payload):
issuer = self.get_settings("OIDC_OP_ISSUER")
if not issuer == payload["iss"]:
raise SuspiciousOperation(
'"iss": %r does not match configured value for OIDC_OP_ISSUER: %r'
% (payload["iss"], issuer)
)
def validate_audience(self, payload):
client_id = self.get_settings("OIDC_RP_CLIENT_ID")
trusted_audiences = self.get_settings("OIDC_TRUSTED_AUDIENCES", [])
trusted_audiences = set(trusted_audiences)
trusted_audiences.add(client_id)
audience = payload["aud"]
if not isinstance(audience, list):
audience = [audience]
audience = set(audience)
if client_id not in audience:
raise SuspiciousOperation(
"Client id not present in audiences: %r" % audience
)
distrusted_audiences = audience.difference(trusted_audiences)
if distrusted_audiences:
raise SuspiciousOperation(
'"aud" contains distrusted audiences: %r' % distrusted_audiences
)
def validate_expiry(self, payload):
expire_time = payload["exp"]
now = time.time()
if now > expire_time:
raise SuspiciousOperation(
"Id-token is expired %r > %r" % (now, expire_time)
)
def validate_id_token(self, payload):
"""Validate the content of the id token as required by OpenID Connect 1.0
This aims to fulfill point 2. 3. and 9. under section 3.1.3.7. ID Token
Validation
"""
self.validate_issuer(payload)
self.validate_audience(payload)
self.validate_expiry(payload)
return payload
def verify_token(self, token, **kwargs):
# Overriding this method to fix issues described here:
# https://github.com/mozilla/mozilla-django-oidc/issues/340
payload = super().verify_token(token, **kwargs)
return self.validate_id_token(payload)
class GregOIDCBackend(ValidatingOIDCBackend):
"""
Custom mozilla OIDC backend for Greg.
TODO:
- Support for id-porten fnr login.
"""
def verify_token(self, token, **kwargs):
payload = super().verify_token(token, **kwargs)
# While mozilla_django_oidc does have support for automatically storing the
# id-token, it is more useful for us to be able to store the actual payload of
# the id-token.
session = self.request.session
session["oidc_id_token_payload"] = {"iat": payload["iat"]}
return payload
def get_username(self, claims):
return "{}{}".format(self.get_settings("OIDC_OP_ISSUER"), claims)
def filter_users_by_claims(self, claims):
# Ideally dataporten should include 'iss' in its claims, so that it can be
# used along with sub to create a unique identifier for the user. However, if
# the id-token has been validated, it means that the 'iss' specified by the
# id-token is equal to what is specified in the configuration (OIDC_OP_ISSUER).
sub = claims.get("sub")
if not sub:
return self.UserModel.objects.none()
username = self.get_username(sub)
try:
user = self.UserModel.objects.filter(username=username)
return user
except self.UserModel.DoesNotExist:
return self.UserModel.objects.none()
def get_or_create_user(self, access_token, id_token, payload):
# This method has been overridden to update the user_profile object
# for a user at login
user = super().get_or_create_user(access_token, id_token, payload)
if user:
# Update or create a user_profile
userinfo = extract_userinfo(
self.get_userinfo(access_token, id_token, payload)
)
self._get_or_create_greg_user_profile(userinfo, user)
return user
def _get_or_create_person(self, userinfo):
# Update any new person info
person, _ = Person.objects.update_or_create(
first_name=userinfo["first_name"],
last_name=userinfo["last_name"],
email=userinfo["email"],
)
person.save()
return person
def _get_or_create_greg_user_profile(self, userinfo, user):
"""
Get or create a GregUserProfile.
We try to match a logged inn user with an existing profile.
If no profile exists, we check if the feide_id is registered.
"""
try:
user_profile = GregUserProfile.objects.get(user=user)
except GregUserProfile.DoesNotExist:
# Check if user is a sponsor
try:
sponsor = Sponsor.objects.get(feide_id=userinfo["userid_feide"])
except Sponsor.DoesNotExist:
sponsor = None
try:
# TODO, match against fnr if using id-porten.
identity = Identity.objects.get(
type="feide_id", value=userinfo["userid_feide"]
)
person = identity.person
except Identity.DoesNotExist:
# Find or create person, and add identity
person = self._get_or_create_person(userinfo)
identity = Identity(
type="feide_id", value=userinfo["userid_feide"], person=person
)
identity.save()
user_profile = GregUserProfile(
user=user,
person=person,
sponsor=sponsor,
userid_feide=userinfo["userid_feide"],
)
user_profile.save()
return user_profile
def create_user(self, claims):
userinfo = extract_userinfo(claims)
username = self.get_username(claims["sub"])
user = self.UserModel(
username=username,
first_name=userinfo["first_name"],
last_name=userinfo["last_name"],
email=userinfo["email"],
)
user.save()
# Create a user_profile if missing
self._get_or_create_greg_user_profile(userinfo, user)
return user
def update_user(self, user, claims):
username = self.get_username(claims["sub"])
user = self.UserModel.objects.get(username=username)
userinfo = extract_userinfo(claims)
for key, new_value in userinfo.items():
current_value = getattr(user, key, None)
if not new_value == current_value:
setattr(user, key, new_value)
user.save()
# Create a user_profile if missing
self._get_or_create_greg_user_profile(userinfo, user)
return user
def provider_logout(request):
redirect_url = settings.OIDC_END_SESSION_ENDPOINT
return redirect_url
# Generated by Django 3.2.7 on 2021-09-22 09:35
import dirtyfields.dirtyfields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("greg", "0004_ou_deleted_active"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="GregUserProfile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"userid_feide",
models.CharField(max_length=150, verbose_name="userid-feide"),
),
(
"person",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="user_profiles",
to="greg.person",
),
),
(
"sponsor",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="user_profiles",
to="greg.sponsor",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model),
),
]
from django.conf import settings
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy
# Create your models here. from greg.models import BaseModel, Person, Sponsor
class GregUserProfile(BaseModel):
"""Link the django user to a Person or Sponsor."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
person = models.ForeignKey(
Person,
on_delete=models.CASCADE,
related_name="user_profiles",
blank=True,
null=True,
)
sponsor = models.ForeignKey(
Sponsor,
on_delete=models.CASCADE,
related_name="user_profiles",
blank=True,
null=True,
)
userid_feide = models.CharField(gettext_lazy("userid-feide"), max_length=150)
from rest_framework.test import APIClient from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
# from django.contrib.auth import get_user_model
import pytest import pytest
from greg.models import ( # from greg.models import (
Consent, # Consent,
Notification, # Notification,
Person, # Person,
Sponsor, # Sponsor,
SponsorOrganizationalUnit, # SponsorOrganizationalUnit,
Identity, # Identity,
Role, # Role,
RoleType, # RoleType,
OrganizationalUnit, # OrganizationalUnit,
ConsentType, # ConsentType,
) # )
@pytest.fixture @pytest.fixture
def client() -> APIClient: def client() -> APIClient:
......
...@@ -6,6 +6,7 @@ from django.urls.resolvers import URLResolver ...@@ -6,6 +6,7 @@ from django.urls.resolvers import URLResolver
from gregui.api import urls as api_urls from gregui.api import urls as api_urls
from gregui.views import TokenCreationView from gregui.views import TokenCreationView
from gregui.api.views.userinfo import UserInfoView
from . import views from . import views
urlpatterns: List[URLResolver] = [ urlpatterns: List[URLResolver] = [
...@@ -18,4 +19,5 @@ urlpatterns: List[URLResolver] = [ ...@@ -18,4 +19,5 @@ urlpatterns: List[URLResolver] = [
path("api/ui/v1/session/", views.SessionView.as_view(), name="api-session"), path("api/ui/v1/session/", views.SessionView.as_view(), name="api-session"),
path("api/ui/v1/whoami/", views.WhoAmIView.as_view(), name="api-whoami"), path("api/ui/v1/whoami/", views.WhoAmIView.as_view(), name="api-whoami"),
path("api/ui/v1/token/<email>", TokenCreationView.as_view()), path("api/ui/v1/token/<email>", TokenCreationView.as_view()),
path("api/ui/v1/userinfo/", UserInfoView.as_view()), # type: ignore
] ]
...@@ -91,6 +91,7 @@ class SessionView(APIView): ...@@ -91,6 +91,7 @@ class SessionView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@staticmethod @staticmethod
# pylint: disable=W0622
def get(request, format=None): def get(request, format=None):
return JsonResponse({"isAuthenticated": True}) return JsonResponse({"isAuthenticated": True})
...@@ -100,5 +101,6 @@ class WhoAmIView(APIView): ...@@ -100,5 +101,6 @@ class WhoAmIView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@staticmethod @staticmethod
# pylint: disable=W0622
def get(request, format=None): def get(request, format=None):
return JsonResponse({"username": request.user.username}) return JsonResponse({"username": request.user.username})
This diff is collapsed.
...@@ -23,6 +23,7 @@ sentry-sdk = "*" ...@@ -23,6 +23,7 @@ sentry-sdk = "*"
whitenoise = "*" whitenoise = "*"
django-reversion = "*" django-reversion = "*"
django-sesame = {extras = ["ua"], version = "^2.4"} django-sesame = {extras = ["ua"], version = "^2.4"}
mozilla-django-oidc = "^2.0.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
Faker = "*" Faker = "*"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment