Skip to content

Commit

Permalink
Add feide verification
Browse files Browse the repository at this point in the history
Adds FEIDE verification using OIDC. The user authenticates with our
FEIDE app, and we use their groups API to retrieve the users groups,
including study programmes. This allows us to verify the students
eligibility to become a member in Abakus, and set their grade and
program automatically.
  • Loading branch information
LudvigHz committed Jul 29, 2023
1 parent 2c1909f commit ef520cc
Show file tree
Hide file tree
Showing 11 changed files with 186 additions and 5 deletions.
2 changes: 2 additions & 0 deletions lego/api/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
from lego.apps.users.views.abakus_groups import AbakusGroupViewSet
from lego.apps.users.views.membership_history import MembershipHistoryViewSet
from lego.apps.users.views.memberships import MembershipViewSet
from lego.apps.users.views.oidc import OIDCViewSet
from lego.apps.users.views.password_change import ChangePasswordViewSet
from lego.apps.users.views.password_reset import (
PasswordResetPerformViewSet,
Expand Down Expand Up @@ -212,4 +213,5 @@
StudentConfirmationRequestViewSet,
basename="student-confirmation-request",
)
router.register(r"oidc", OIDCViewSet, basename="oidc")
router.register(r"webhooks-stripe", StripeWebhook, basename="webhooks-stripe")
21 changes: 21 additions & 0 deletions lego/apps/users/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from enum import Enum

MALE = "male"
FEMALE = "female"
OTHER = "other"
Expand Down Expand Up @@ -72,6 +74,25 @@
USER_GROUP = "Users"
MEMBER_GROUP = "Abakus"


class FSGroup(Enum):
@classmethod
def values(cls) -> list[str]:
return [e.value for e in cls]

MTDT = "fc:fs:fs:prg:ntnu.no:MTDT"
MTKOM = "fc:fs:fs:prg:ntnu.no:MTKOM"
MIDT = "fc:fs:fs:prg:ntnu.no:MIDT"
MSTCNNS = "fc:fs:fs:prg:ntnu.no:mstcnns"


AbakusGradeFSMapping = {
FSGroup.MTDT: FIRST_GRADE_DATA,
FSGroup.MTKOM: FIRST_GRADE_KOMTEK,
FSGroup.MIDT: FOURTH_GRADE_DATA,
FSGroup.MSTCNNS: FOURTH_GRADE_KOMTEK,
}

STUDENT_EMAIL_DOMAIN = "stud.ntnu.no"

GROUP_COMMITTEE = "komite"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.0.10 on 2023-07-28 13:19

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('users', '0039_user_github_username'),
]

operations = [
migrations.AddField(
model_name='user',
name='student_verification_status',
field=models.BooleanField(blank=True, null=True),
),
]
23 changes: 22 additions & 1 deletion lego/apps/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ class User(
validators=[student_username_validator, ReservedNameValidator()],
error_messages={"unique": "A user has already verified that student username."},
)
student_verification_status = models.BooleanField(null=True, blank=True)
first_name = models.CharField("first name", max_length=50, blank=False)
last_name = models.CharField("last name", max_length=30, blank=False)
allergies = models.CharField("allergies", max_length=500, blank=True)
Expand Down Expand Up @@ -441,8 +442,28 @@ def email_address(self):
def profile_picture(self, value):
self.picture = value

def verify_student(self, feide_groups) -> bool:
# if self.is_verified_student():
# return True

for group in feide_groups:
if group["id"] in constants.FSGroup.values():
grade_group = AbakusGroup.objects.get(
name=constants.AbakusGradeFSMapping[constants.FSGroup(group["id"])]
)
grade_group.add_user(self)
self.student_verification_status = True
self.save()
return True

# Student has no allowed groups
self.student_verification_status = False
self.save()
return False

def is_verified_student(self):
return self.student_username is not None
return False
return self.student_verification_status

def get_short_name(self):
return self.first_name
Expand Down
4 changes: 4 additions & 0 deletions lego/apps/users/serializers/student_confirmation.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ def validate_captcha_response(self, captcha_response):
if not verify_captcha(captcha_response):
raise exceptions.ValidationError("invalid_captcha")
return captcha_response


class FeideAuthorizeSerializer(serializers.Serializer):
url = serializers.URLField()
85 changes: 85 additions & 0 deletions lego/apps/users/views/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from django.conf import settings
from django.core.cache import caches
from django.http import HttpResponse
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

import requests
import sentry_sdk
from authlib.integrations.base_client.errors import MismatchingStateError
from authlib.integrations.django_client import OAuth
from structlog import get_logger

from lego.apps.users.serializers.student_confirmation import FeideAuthorizeSerializer

log = get_logger()

oauth_cache = caches["oauth"]

oauth = OAuth(cache=oauth_cache)
oauth.register(
name="feide",
client_id=settings.FEIDE_OIDC_CLIENT_ID,
client_secret=settings.FEIDE_OIDC_CLIENT_SECRET,
server_metadata_url=settings.FEIDE_OIDC_CONFIGURATION_ENDPOINT,
client_kwargs={"scope": "openid"},
)


class OIDCViewSet(viewsets.GenericViewSet):
permission_classes = (IsAuthenticated,)

@action(detail=False, methods=["GET"])
def authorize(self, request: Request) -> Response:
# TODO Handle already student
if request.user.is_verified_student:
return Response({"status": "verified"})
redirect_uri = f"{settings.FRONTEND_URL}/users/me/settings/student-confirmation"
auth_uri = oauth.feide.authorize_redirect(request, redirect_uri).url
return Response(
FeideAuthorizeSerializer({"url": auth_uri}).data,
status=status.HTTP_200_OK,
)

@action(detail=False, methods=["GET"])
def validate(self, request: Request) -> HttpResponse:
programme_names = []

try:
token = oauth.feide.authorize_access_token(request)
status = "success"
except MismatchingStateError as e:
sentry_sdk.capture_message("Failed while validating access token", "fatal")
sentry_sdk.capture_exception(error=e)
return Response(
{
"status": "error",
"detail": "Error when validating OAUTH acccess token",
}
)

try:
groups_res = requests.get(
"https://groups-api.dataporten.no/groups/me/groups",
headers={"Authorization": f"Bearer {token['access_token']}"},
)
groups_res.raise_for_status()

groups = groups_res.json()
student_validation = request.user.verify_student(feide_groups=groups)
if not student_validation:
status = "unauthorized"
programme_names = [
p["displayName"]
for p in filter(lambda g: g["type"] == "fc:fs:prg", groups)
]

except requests.HTTPError as e:
sentry_sdk.capture_message("Failed while obtaining FEIDE groups", "fatal")
sentry_sdk.capture_exception(error=e)
status = "error"

return Response({"status": status, "studyProgrammes": programme_names})
8 changes: 8 additions & 0 deletions lego/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@
SMTP_SSL_CERTIFICATE = os.environ.get("SMTP_SSL_CERTIFICATE")
SMTP_SSL_KEY = os.environ.get("SMTP_SSL_KEY")

FEIDE_OIDC_CLIENT_ID = os.environ.get("FEIDE_OIDC_CLIENT_ID")
FEIDE_OIDC_CLIENT_SECRET = os.environ.get("FEIDE_OIDC_CLIENT_SECRET")
FEIDE_OIDC_CONFIGURATION_ENDPOINT = os.environ.get(
"FEIDE_OIDC_CONFIGURATION_ENDPOINT",
"https://auth.dataporten.no/.well-known/openid-configuration",
)
FEIDE_GROUPS_ENDPOINT = "https://groups-api.dataporten.no/groups/me/groups"

if os.environ.get("GSUITE_CREDENTIALS"):
GSUITE_CREDENTIALS = json.loads(
base64.b64decode(os.environ.get("GSUITE_CREDENTIALS")), strict=False # type: ignore
Expand Down
7 changes: 6 additions & 1 deletion lego/settings/development.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/0",
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
}
},
"oauth": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/9",
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
},
}

EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
Expand Down
6 changes: 4 additions & 2 deletions lego/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
DATABASES = {"default": env.db()}

# Cache
CACHES = {"default": env.cache(backend="django_redis.cache.RedisCache")}

CACHES = {
"default": env.cache(backend="django_redis.cache.RedisCache"),
"oauth": env.cache(env("OAUTH_CACHE"), backend="django_redis.cache.RedisCache"),
}
# Email / We may enable the celery email backend.
# See https://github.com/joke2k/django-environ#email-settings for how to configure
EMAIL_CONFIG = env.email()
Expand Down
16 changes: 15 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ premailer = "3.10.0"
Pygments = "2.15.1"
Markdown = "3.4.3"
coreapi = "2.3.3"
authlib = "^1.2.1"

[tool.poetry.group.dev.dependencies]
django-debug-toolbar = "4.1.0"
Expand Down

0 comments on commit ef520cc

Please sign in to comment.