Skip to content
Draft
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ dev-dependencies = [
"pytest-django==4.11.1",
"pytest-lazy-fixtures>=1",
"pytest-mock==3.15.1",
"pytest-xdist[psutil]>=3.8.0",
"responses==0.25.8",
"ruff",
"scriv[toml]",
Expand Down
2 changes: 1 addition & 1 deletion src/apigateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Your application configuration will need some settings added to it. Reasonable d
These settings are needed for your environment:

- `MITOL_APIGATEWAY_LOGOUT_URL` - the URL that APISIX uses for logout. This needs to be set in your APISIX configuration; the corresponding setting is `logout_path`. Defaults to `/logout`.
- `MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_DEST` - the URL that the logout view should send users when they log out by default. (You can programmatically set a destination but you should also have a default.) Defaults to `/app`.
- `MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_URL` - the URL that the logout view should send users when they log out by default. (You can programmatically set a destination but you should also have a default.) Defaults to `/app`.

These settings are likely to need adjustment for your environment:

Expand Down
11 changes: 3 additions & 8 deletions src/apigateway/mitol/apigateway/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,7 @@

# Set to the URL that APISIX uses for logout.
MITOL_APIGATEWAY_LOGOUT_URL = "/logout"
MITOL_APIGATEWAY_HEADER_NAME = "HTTP_X_USERINFO"

# Set to the default URL the user should be sent to when logging out.
# If there's no redirect URL specified otherwise, the user gets sent here.
MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_DEST = "/app"

# Set to the list of hosts the app is allowed to redirect to.
MITOL_APIGATEWAY_ALLOWED_REDIRECT_HOSTS = [
"localhost",
]
MITOL_APIGATEWAY_LOGOUT_NEXT_URL_COOKIE_TTL = 60
MITOL_APIGATEWAY_LOGOUT_NEXT_URL_COOKIE_NAME = "logout-next"
10 changes: 3 additions & 7 deletions src/apigateway/mitol/apigateway/urls.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
"""URL routes for the apigateway app. Mostly for testing."""
"""URL routes for the apigateway app."""

from django.urls import path
from django.urls import re_path

from mitol.apigateway.views import ApiGatewayLogoutView

urlpatterns = [
path(
"applogout/",
ApiGatewayLogoutView.as_view(),
name="logout",
),
re_path(r"^logout", ApiGatewayLogoutView.as_view(), name="logout"),
]
9 changes: 9 additions & 0 deletions src/apigateway/mitol/apigateway/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""API gateway utils"""

from django.conf import settings
from django.http.request import HttpRequest


def has_gateway_auth(request: HttpRequest) -> bool:
"""Return True if the request has auth information from the API gateway"""
return request.META.get(settings.MITOL_APIGATEWAY_HEADER_NAME)
92 changes: 33 additions & 59 deletions src/apigateway/mitol/apigateway/views.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,13 @@
"""Custom logout view for the API Gateway."""

import logging

from django.conf import settings
from django.contrib.auth import logout
from django.shortcuts import redirect
from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View
from django.http.request import HttpRequest

log = logging.getLogger(__name__)
from mitol.apigateway.utils import has_gateway_auth
from mitol.authentication.views.auth import AuthRedirectView


def get_redirect_url(request):
"""
Get the redirect URL from the request.

Args:
request: Django request object

Returns:
str: Redirect URL
"""
log.debug("views.get_redirect_url: Request GET is: %s", request.GET.get("next"))
log.debug(
"views.get_redirect_url: Request cookie is: %s", request.COOKIES.get("next")
)

next_url = request.GET.get("next") or request.COOKIES.get("next")
log.debug("views.get_redirect_url: Redirect URL (before valid check): %s", next_url)

if request.COOKIES.get("next"):
# Clear the cookie after using it
log.debug("views.get_redirect_url: Popping the next cookie")

request.COOKIES.pop("next", None)

return (
next_url
if next_url
and url_has_allowed_host_and_scheme(
next_url, allowed_hosts=settings.MITOL_APIGATEWAY_ALLOWED_REDIRECT_HOSTS
)
else settings.MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_DEST
)


class ApiGatewayLogoutView(View):
class ApiGatewayLogoutView(AuthRedirectView):
"""
Log the user out.

Expand All @@ -61,27 +23,39 @@ class ApiGatewayLogoutView(View):
Keycloak will throw an error.)
"""

next_url_cookie_names = [settings.MITOL_APIGATEWAY_LOGOUT_NEXT_URL_COOKIE_NAME]

def get_redirect_url(self, request: HttpRequest) -> tuple[str, bool]:
"""Get the redirect url"""
next_url, prune_cookies = super().get_redirect_url(request)

if has_gateway_auth(request):
# Still logged in via Apisix/Keycloak, so log out there
# and use cookies to preserve the next url
return settings.MITOL_APIGATEWAY_LOGOUT_URL, False

return next_url, prune_cookies

def get(
self,
request,
*args, # noqa: ARG002
**kwargs, # noqa: ARG002
*args,
**kwargs,
):
"""
GET endpoint reached after logging a user out from Keycloak
GET endpoint reached to logout the user
"""
user = getattr(request, "user", None)
user_redirect_url = get_redirect_url(request)
log.debug(
"views.ApiGatewayLogoutView.get: User redirect URL: %s", user_redirect_url
)
if user and user.is_authenticated:
logout(request)
response = super().get(request, *args, **kwargs)

if has_gateway_auth(request):
# we can only preserve the next url via cookies because APISIX
# won't accept a post_logout_redirect_url
next_url, _ = super().get_redirect_url(request)

response.set_cookie(
settings.MITOL_APIGATEWAY_LOGOUT_NEXT_URL_COOKIE_NAME,
value=next_url,
max_age=settings.MITOL_APIGATEWAY_LOGOUT_NEXT_URL_COOKIE_TTL,
)

if request.META.get(settings.MITOL_APIGATEWAY_USERINFO_HEADER_NAME):
# Still logged in via Apisix/Keycloak, so log out there as well
log.debug("views.ApiGatewayLogoutView.get: Send to APISIX logout URL")
return redirect(settings.MITOL_APIGATEWAY_LOGOUT_URL)
else:
log.debug("views.ApiGatewayLogoutView.get: Send to %s", user_redirect_url)
return redirect(user_redirect_url)
return response
9 changes: 9 additions & 0 deletions src/authentication/mitol/authentication/settings/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from mitol.common.envs import get_list_literal

# Set to the default URL the user should be sent to when logging out.
# If there's no redirect URL specified otherwise, the user gets sent here.
MITOL_DEFAULT_POST_LOGOUT_URL = "/app"

MITOL_ALLOWED_REDIRECT_HOSTS = get_list_literal(
name="ALLOWED_REDIRECT_HOSTS", description="Allowed redirect hostnames", default=[]
)
9 changes: 9 additions & 0 deletions src/authentication/mitol/authentication/urls/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""URL configurations for authentication"""

from django.urls import re_path
from mitol.authentication.views import LoginRedirectView, LogoutRedirectView

urlpatterns = [
re_path(r"^logout", LogoutRedirectView.as_view(), name="logout"),
re_path(r"^login", LoginRedirectView.as_view(), name="login"),
]
88 changes: 88 additions & 0 deletions src/authentication/mitol/authentication/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import logging

from django.conf import settings
from django.http.request import HttpRequest
from django.utils.http import url_has_allowed_host_and_scheme

log = logging.getLogger()


def get_redirect_url(
request: HttpRequest,
*,
param_names: list[str] | None = None,
cookie_names: list[str] | None = None,
) -> str:
"""
Get the redirect URL from the request.

Args:
request: Django request object
param_names: Names of the GET parameters to look for the redirect URL;
first match will be used.
cookie_names: Names of the cookies to look for the redirect URL;
first match will be used.

Returns:
str: Redirect URL
"""
param_next_url = get_redirect_url_from_params(request, param_names)
cookie_next_url = get_redirect_url_from_cookies(request, cookie_names)

log.debug("views.get_redirect_url: Request param is: %s", param_next_url)
log.debug("views.get_redirect_url: Request cookie is: %s", cookie_next_url)

next_url = param_next_url or cookie_next_url

log.debug("mitol.authentication.utils.get_redirect_url: next_url='%s'", next_url)

return next_url or settings.MITOL_DEFAULT_POST_LOGOUT_URL or "/"


def get_redirect_url_from_cookies(
request: HttpRequest, cookie_names: list[str]
) -> str | None:
"""
Get the redirect URL from the request cookies.

Args:
request: Django request object
cookie_names: Names of the cookies to look for the redirect URL;
first match will be used.

Returns:
str: Redirect URL
"""
return _get_redirect_url(request.COOKIES, cookie_names)


def get_redirect_url_from_params(
request: HttpRequest, param_names: list[str]
) -> str | None:
"""
Get the redirect URL from the request params.

Args:
request: Django request object
param_names: Names of the GET parameter to look for the redirect URL;
first match will be used.

Returns:
str: Redirect URL
"""

return _get_redirect_url(request.GET, param_names)


def _get_redirect_url(record: dict[str, str], keys: list[str]) -> str | None:
"""
Get a valid redirect
"""
for key in keys:
next_url = record.get(key)
if next_url and url_has_allowed_host_and_scheme(
next_url, allowed_hosts=settings.MITOL_ALLOWED_REDIRECT_HOSTS
):
return next_url

return None
69 changes: 69 additions & 0 deletions src/authentication/mitol/authentication/views/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Authentication views"""

import logging

from django.contrib.auth import logout
from django.http.request import HttpRequest
from django.shortcuts import redirect
from django.views import View
from mitol.authentication.utils import get_redirect_url

log = logging.getLogger(__name__)


class AuthRedirectView(View):
"""Base class for auth views that need to do a redirect based on params/cookies"""

next_url_param_names = ["next"]
next_url_cookie_names = []

def get_redirect_url(self, request: HttpRequest) -> tuple[str, bool]:
"""Get the redirect url based on params or cookies"""
return get_redirect_url(
request,
param_names=self.next_url_param_names,
cookie_names=self.next_url_cookie_names,
), True

def prune_next_url_cookies(self, request: HttpRequest):
"""Prune the next url cookies"""
for cookie_name in self.next_url_cookie_names:
request.COOKIES.pop(cookie_name, None)

def get(
self,
request,
*_args,
**_kwargs,
):
"""
GET endpoint reached after logging a user out from Keycloak
"""
redirect_url, prune_cookies = self.get_redirect_url(request)

if prune_cookies:
self.prune_next_url_cookies(request)

return redirect(redirect_url)


class LogoutRedirectView(AuthRedirectView):
"""
Log out the user from django and redirect
"""

def get(
self,
request,
*args,
**kwargs,
):
"""
GET endpoint reached to logout the user
"""
user = getattr(request, "user", None)

if user and user.is_authenticated:
logout(request)

return super().get(request, *args, **kwargs)
1 change: 1 addition & 0 deletions testapp/main/settings/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"mitol.common.settings.base",
"mitol.common.settings.webpack",
"mitol.mail.settings.email",
"mitol.authentication.settings.auth",
"mitol.authentication.settings.touchstone",
"mitol.authentication.settings.djoser_settings",
"mitol.payment_gateway.settings.cybersource",
Expand Down
2 changes: 1 addition & 1 deletion testapp/main/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@

FEATURES = {}

MITOL_DEFAULT_POST_LOGOUT_URL = "/app-after-logout"
MITOL_APIGATEWAY_LOGOUT_URL = "/logout"
MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_DEST = "/app-after-logout"
Loading