Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow users to authenticate with Azure in order to capture their Justice email address #1280

Merged
merged 11 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions controlpanel/api/migrations/0036_user_justice_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.7 on 2024-03-26 12:21

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("api", "0035_user_is_bedrock_enabled"),
]

operations = [
migrations.AddField(
model_name="user",
name="justice_email",
field=models.EmailField(blank=True, max_length=254, null=True, unique=True),
),
]
1 change: 1 addition & 0 deletions controlpanel/api/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class User(AbstractUser):
default=VOID,
)
is_bedrock_enabled = models.BooleanField(default=False)
justice_email = models.EmailField(blank=True, null=True, unique=True)

REQUIRED_FIELDS = ["email", "auth0_id"]

Expand Down
28 changes: 28 additions & 0 deletions controlpanel/frontend/jinja2/justice_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{% extends "base.html" %}

{% set page_name = "home" %}
{% set hide_nav = True %}
{% set page_title = "Hello " ~ ( request.user.name if request.user ) %}

{% block content %}

<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<h1 class="govuk-heading-xl">Authenticate with your Justice identity</h1>
<p class="govuk-body-l">As part of upcoming work to offer new tools and services, all Analytical Platform will need to authenticate with their Justice identity so that we can store your @justice.gov.uk email address.</p>
<p class="govuk-body">You will need to complete authentication by 30th April 2024. If you do not currently have a @justice.gov.uk email address, <a href="#" class="govuk-link">see our guidance on requesting one.</a></p>
<div class="govuk-button-group">
<form method="POST" action=".">
{{ csrf_input }}
<button type="submit" class="govuk-button" data-module="govuk-button">
Authenticate with Justice identity
</button>
<a class="govuk-button govuk-button--secondary" href="{{ url('list-tools') }}">
Skip for now
</a>
</form>
</div>
</div>
</div>

{% endblock %}
1 change: 1 addition & 0 deletions controlpanel/frontend/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

urlpatterns = [
path("", views.IndexView.as_view(), name="index"),
path("oidc/entraid/auth/", views.EntraIdAuthView.as_view(), name="entraid-auth"),
path("oidc/logout/", views.LogoutView.as_view(), name="oidc_logout"),
path("datasources/", views.AdminBucketList.as_view(), name="list-all-datasources"),
path(
Expand Down
57 changes: 46 additions & 11 deletions controlpanel/frontend/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Standard library
from django.conf import settings
# Third-party
from django.http import HttpResponseRedirect
from django.urls import reverse
Expand All @@ -6,6 +8,7 @@

# First-party/Local
from controlpanel.frontend.views.accessibility import Accessibility
from controlpanel.frontend.views.auth import EntraIdAuthView

# isort: off
from controlpanel.frontend.views.app import (
Expand Down Expand Up @@ -69,33 +72,65 @@
ReleaseList,
)
from controlpanel.frontend.views.reset import ResetHome
from controlpanel.frontend.views.task import TaskList
from controlpanel.frontend.views.tool import RestartTool, ToolList
from controlpanel.frontend.views.user import (
EnableBedrockUser,
ResetMFA,
SetSuperadmin,
EnableBedrockUser,
UserDelete,
UserDetail,
UserList,
)
from controlpanel.oidc import OIDCLoginRequiredMixin
from controlpanel.frontend.views.task import TaskList
from controlpanel.oidc import OIDCLoginRequiredMixin, get_code_challenge, oauth


class IndexView(OIDCLoginRequiredMixin, TemplateView):
template_name = "home.html"
http_method_names = ["get", "post"]

def get(self, request):
def get_template_names(self):
"""
Returns the template to instruct users to authenticate with their Justice
account, unless this has already been captured.
"""
if not self.request.user.justice_email:
return ["justice_email.html"]

return [self.template_name]

def get(self, request, *args, **kwargs):
"""
If the user is a superuser display the home page (containing useful
admin related links). Otherwise, redirect the user to the list of the
tools they currently have available on the platform.
If the user has not authenticated with their Justice account, displays page to
ask them to authenticate, to allow us to capture their email address.
If their Justice email has been captured, normal users are redirected to their
tools. Superusers are displayed the home page (containing useful
admin related links).
"""

if request.user.is_superuser:
return super().get(request)
else:
# Redirect to the tools page.
return HttpResponseRedirect(reverse("list-tools"))
return super().get(request, *args, **kwargs)

# TODO add feature request check
if settings.features.justice_auth.enabled and not request.user.justice_email:
return super().get(request, *args, **kwargs)

# Redirect to the tools page.
return HttpResponseRedirect(reverse("list-tools"))

def post(self, request, *args, **kwargs):
"""
Redirects user to authenticate with Azure EntraID.
"""
if not settings.features.justice_auth.enabled and not request.user.is_superuser:
return self.http_method_not_allowed(request, *args, **kwargs)

redirect_uri = request.build_absolute_uri(reverse("entraid-auth"))
return oauth.azure.authorize_redirect(
request,
redirect_uri,
code_challenge=get_code_challenge(),
)


class LogoutView(OIDCLogoutView):
Expand Down
60 changes: 60 additions & 0 deletions controlpanel/frontend/views/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Standard library

# Third-party
import sentry_sdk
from authlib.integrations.django_client import OAuthError
from django.conf import settings
from django.contrib import messages
from django.http import HttpResponseRedirect, Http404
from django.urls import reverse
from django.views import View

# First-party/Local
from controlpanel.oidc import OIDCLoginRequiredMixin, oauth


class EntraIdAuthView(OIDCLoginRequiredMixin, View):
"""
This view is used as the callback after a user authenticates with their Justice
identity via Azure EntraID, in order to capture a users Justice email address.
"""
http_method_names = ["get"]

def _get_access_token(self):
"""
Attempts to valiate and return the access token
"""
try:
token = oauth.azure.authorize_access_token(self.request)
except OAuthError as error:
sentry_sdk.capture_exception(error)
token = None
return token

def get(self, request, *args, **kwargs):
"""
Attempts to retrieve the auth token, and update the user.
"""
if not settings.features.justice_auth.enabled and not request.user.is_superuser:
raise Http404()

token = self._get_access_token()
if not token:
messages.error(request, "Something went wrong, please try again")
return HttpResponseRedirect(reverse("index"))

self.update_user(token=token)
messages.success(
request=request,
message=f"Successfully authenticated with your email {request.user.justice_email}",
)
return HttpResponseRedirect(reverse("index"))

def update_user(self, token):
"""
Update user with details from the ID token returned by the provided EntraID
access token
"""
email = token["userinfo"]["email"]
self.request.user.justice_email = email
self.request.user.save()
17 changes: 17 additions & 0 deletions controlpanel/oidc.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# Standard library
import base64
import hashlib
from urllib.parse import urlencode

# Third-party
import structlog
from authlib.common.security import generate_token
from authlib.integrations.django_client import OAuth
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import SuspiciousOperation
Expand Down Expand Up @@ -96,3 +100,16 @@ def dispatch(self, request, *args, **kwargs):
if token_expiry_seconds and current_seconds > token_expiry_seconds:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)


def get_code_challenge():
code_verifier = generate_token(64)
digest = hashlib.sha256(code_verifier.encode()).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()


oauth = OAuth()
oauth.register(
name="azure",
**settings.AUTHLIB_OAUTH_CLIENTS["azure"]
)
20 changes: 20 additions & 0 deletions controlpanel/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,26 @@
# The audience for Control Panel RESTful APIs
OIDC_CPANEL_API_AUDIENCE = os.environ.get("OIDC_CPANEL_API_AUDIENCE")

# For authentication with EntraID
AZURE_TENANT_ID = os.environ.get("AZURE_TENANT_ID")
AZURE_OP_CONF_URL = f"https://login.microsoftonline.com/{AZURE_TENANT_ID}/v2.0/.well-known/openid-configuration"
AZURE_RP_SCOPES = "openid email profile"
AZURE_CODE_CHALLENGE_METHOD = os.environ.get("AZURE_CODE_CHALLENGE_METHOD", "S256")
AUTHLIB_OAUTH_CLIENTS = {
"azure": {
"client_id": os.environ.get("AZURE_CLIENT_ID"),
# TODO client_secret is not strictly required but would be better to use
"server_metadata_url": AZURE_OP_CONF_URL,
"client_kwargs": {
"scope": AZURE_RP_SCOPES,
"response_type": "code",
"token_endpoint_auth_method": "none",
"code_challenge_method": AZURE_CODE_CHALLENGE_METHOD,
},

}
}

# -- Security

SECRET_KEY = os.environ.get("SECRET_KEY", "change-me")
Expand Down
1 change: 0 additions & 1 deletion controlpanel/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,4 @@
path("metrics", exports.ExportToDjangoView, name="prometheus-django-metrics"),
]


urlpatterns += staticfiles_urlpatterns()
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
asgiref==3.7.2
auth0-python==4.5.0
authlib==1.3.0
beautifulsoup4==4.12.2
boto3==1.34.64
celery[sqs]==5.3.1
Expand Down
4 changes: 4 additions & 0 deletions settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ enabled_features:
_HOST_dev: false
_HOST_prod: false
_HOST_alpha: false
justice_auth:
_DEFAULT: false
_HOST_dev: true
_HOST_test: true


AWS_SERVICE_URL:
Expand Down
52 changes: 52 additions & 0 deletions tests/frontend/views/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Third-party
import pytest
from authlib.integrations.base_client import OAuthError
from django.urls import reverse, reverse_lazy
from mock import patch
from pytest_django.asserts import assertContains


class TestEntraIdAuthView:
url = reverse_lazy("entraid-auth")

def test_unauthorised(self, client):
response = client.get(self.url)

assert response.status_code == 302

@patch("django.conf.settings.features.justice_auth.enabled", False)
def test_justice_auth_feature_flag_disabled_for_normal_user(self, users, client):
user = users["normal_user"]

client.force_login(user)
response = client.get(self.url)

assert response.status_code == 404

@patch("controlpanel.frontend.views.auth.oauth")
def test_success(self, oauth, client, users):
oauth.azure.authorize_access_token.return_value = {
"userinfo": {"email": "[email protected]"},
}
user = users["normal_user"]
assert user.justice_email is None

client.force_login(user)
response = client.get(self.url, follow=True)

user.refresh_from_db()
assert user.justice_email == "[email protected]"
assertContains(response, "Successfully authenticated with your email [email protected]")

@patch("controlpanel.frontend.views.auth.oauth")
def test_failure(self, oauth, client, users):
oauth.azure.authorize_access_token.side_effect = OAuthError()
user = users["normal_user"]
assert user.justice_email is None

client.force_login(user)
response = client.get(self.url, follow=True)

user.refresh_from_db()
assert user.justice_email is None
assertContains(response, "Something went wrong, please try again")
Loading
Loading