diff --git a/.env.example b/.env.example index e461c236..b203137d 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,8 @@ POSTHOG_API_HOST=https://app.posthog.com POSTHOG_API_TOKEN= MITOL_UE_PAYMENT_INTERSTITIAL_DEBUG=False +MITOL_UE_PAYMENT_BASKET_ROOT=http://learn.odl.local:8073/cart/ +MITOL_UE_PAYMENT_BASKET_CHOOSER=http://learn.odl.local:8073/cart/ MITOL_PAYMENT_GATEWAY_CYBERSOURCE_ACCESS_KEY=sample-setting MITOL_PAYMENT_GATEWAY_CYBERSOURCE_PROFILE_ID=sample-setting diff --git a/README.md b/README.md index baff158c..ed1dfde9 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,15 @@ The following settings must be configured before running the app: The client secret for the OIDC client. No default - you will need to get this from the Keycloak admin, even if you're using the pack-in Keycloak instance. +- `MITOL_UE_PAYMENT_BASKET_ROOT` + + The root URL for the basket page. This defaults to `/cart/` (which is the cart test mule app), but if you're testing the actual frontend, this needs to be set to go there (i.e. `http://learn.odl.local:8062/cart/`). Make sure this has a `/` appended since it is used to _generate_ a URL. + +- `MITOL_UE_PAYMENT_BASKET_CHOOSER` + + The URL for an optional "chooser" page. If the `establish_session` call happens without a valid system slug, the user gets sent here so they can choose which cart they want to see. + + ### Loading and Accessing Data You'll need an integrated system and product for that system to be able to do much of anything. A management command exists to create the test data: `generate_test_data`. This will create a system and add some products with random (but usable) prices in it. diff --git a/config/apisix/apisix.yaml b/config/apisix/apisix.yaml index 85c8a943..ee96cade 100644 --- a/config/apisix/apisix.yaml +++ b/config/apisix/apisix.yaml @@ -15,6 +15,9 @@ routes: - "/api/v0/payments/checkout/result/*" - "/static/*" - "/api/v0/schema/*" + - "/auth/*" + - "/_/v0/meta/apisix_test_request/" + - "/logged_out/" - id: 2 name: "ue-default" desc: "Wildcard route for the rest of the system - authentication required" @@ -30,6 +33,17 @@ routes: bearer_only: false introspection_endpoint_auth_method: "client_secret_post" ssl_verify: false + logout_path: "/logout/" + post_logout_redirect_uri: ${{UE_LOGOUT_URL}} + cors: + allow_origins: "**" + allow_methods: "**" + allow_headers: "**" + allow_credential: true + response-rewrite: + headers: + set: + Referrer-Policy: "origin" uris: - "/*" diff --git a/docker-compose.yml b/docker-compose.yml index 835beceb..9dfcf1ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,6 +84,7 @@ services: - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} - KEYCLOAK_DISCOVERY_URL=${KEYCLOAK_DISCOVERY_URL:-https://kc.odl.local:7443/realms/ol-local/.well-known/openid-configuration} - APISIX_PORT=${APISIX_PORT:-9080} + - UE_LOGOUT_URL=${UE_LOGOUT_URL:-http://ue.odl.local:9080/auth/logout/} ports: - 9080:9080 - 9180:9180 diff --git a/frontends/api/src/generated/v0/api.ts b/frontends/api/src/generated/v0/api.ts index 7ca06886..f6c79a1b 100644 --- a/frontends/api/src/generated/v0/api.ts +++ b/frontends/api/src/generated/v0/api.ts @@ -1925,12 +1925,13 @@ export const PaymentsApiAxiosParamCreator = function (configuration?: Configurat }, /** * Retrives the current user\'s baskets, one per system. + * @param {number} [integrated_system] * @param {number} [limit] Number of results to return per page. * @param {number} [offset] The initial index from which to return the results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - paymentsBasketsList: async (limit?: number, offset?: number, options: RawAxiosRequestConfig = {}): Promise => { + paymentsBasketsList: async (integrated_system?: number, limit?: number, offset?: number, options: RawAxiosRequestConfig = {}): Promise => { const localVarPath = `/api/v0/payments/baskets/`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -1943,6 +1944,10 @@ export const PaymentsApiAxiosParamCreator = function (configuration?: Configurat const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; + if (integrated_system !== undefined) { + localVarQueryParameter['integrated_system'] = integrated_system; + } + if (limit !== undefined) { localVarQueryParameter['limit'] = limit; } @@ -2116,13 +2121,14 @@ export const PaymentsApiFp = function(configuration?: Configuration) { }, /** * Retrives the current user\'s baskets, one per system. + * @param {number} [integrated_system] * @param {number} [limit] Number of results to return per page. * @param {number} [offset] The initial index from which to return the results. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async paymentsBasketsList(limit?: number, offset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.paymentsBasketsList(limit, offset, options); + async paymentsBasketsList(integrated_system?: number, limit?: number, offset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.paymentsBasketsList(integrated_system, limit, offset, options); const index = configuration?.serverIndex ?? 0; const operationBasePath = operationServerMap['PaymentsApi.paymentsBasketsList']?.[index]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); @@ -2208,7 +2214,7 @@ export const PaymentsApiFactory = function (configuration?: Configuration, baseP * @throws {RequiredError} */ paymentsBasketsList(requestParameters: PaymentsApiPaymentsBasketsListRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.paymentsBasketsList(requestParameters.limit, requestParameters.offset, options).then((request) => request(axios, basePath)); + return localVarFp.paymentsBasketsList(requestParameters.integrated_system, requestParameters.limit, requestParameters.offset, options).then((request) => request(axios, basePath)); }, /** * Retrieve a basket for the current user. @@ -2295,6 +2301,13 @@ export interface PaymentsApiPaymentsBasketsCreateFromProductCreateRequest { * @interface PaymentsApiPaymentsBasketsListRequest */ export interface PaymentsApiPaymentsBasketsListRequest { + /** + * + * @type {number} + * @memberof PaymentsApiPaymentsBasketsList + */ + readonly integrated_system?: number + /** * Number of results to return per page. * @type {number} @@ -2407,7 +2420,7 @@ export class PaymentsApi extends BaseAPI { * @memberof PaymentsApi */ public paymentsBasketsList(requestParameters: PaymentsApiPaymentsBasketsListRequest = {}, options?: RawAxiosRequestConfig) { - return PaymentsApiFp(this.configuration).paymentsBasketsList(requestParameters.limit, requestParameters.offset, options).then((request) => request(this.axios, this.basePath)); + return PaymentsApiFp(this.configuration).paymentsBasketsList(requestParameters.integrated_system, requestParameters.limit, requestParameters.offset, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 626401e0..8ad471bf 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -309,6 +309,10 @@ paths: operationId: payments_baskets_list description: Retrives the current user's baskets, one per system. parameters: + - in: query + name: integrated_system + schema: + type: integer - name: limit required: false in: query diff --git a/payments/views/v0/__init__.py b/payments/views/v0/__init__.py index 685fe574..2ce3f46d 100644 --- a/payments/views/v0/__init__.py +++ b/payments/views/v0/__init__.py @@ -9,6 +9,7 @@ from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.generic import View +from django_filters import rest_framework as filters from drf_spectacular.utils import ( OpenApiParameter, OpenApiResponse, @@ -54,9 +55,24 @@ # Baskets +class BasketFilter(filters.FilterSet): + """Filter class for Basket, just so we can filter by integrated system.""" + + class Meta: + """Meta class for BasketFilter""" + + model = Basket + fields = ["integrated_system"] + + @extend_schema_view( list=extend_schema( - description=("Retrives the current user's baskets, one per system.") + description=("Retrives the current user's baskets, one per system."), + parameters=[ + OpenApiParameter( + "integrated_system", OpenApiTypes.INT, OpenApiParameter.QUERY + ), + ], ), retrieve=extend_schema( description="Retrieve a basket for the current user.", @@ -69,11 +85,16 @@ class BasketViewSet(ReadOnlyModelViewSet): """API view set for Basket""" serializer_class = BasketWithProductSerializer - permission_classes = [IsAuthenticated] + permission_classes = (IsAuthenticated,) + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = BasketFilter def get_queryset(self): """Return only baskets owned by this user.""" + if getattr(self, "swagger_fake_view", False): + return Basket.objects.none() + return Basket.objects.filter(user=self.request.user).all() diff --git a/poetry.lock b/poetry.lock index bab55786..ffee2288 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1103,6 +1103,20 @@ files = [ django = ">=4.2.9" sqlparse = ">=0.2" +[[package]] +name = "django-extensions" +version = "3.2.3" +description = "Extensions for Django" +optional = false +python-versions = ">=3.6" +files = [ + {file = "django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a"}, + {file = "django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401"}, +] + +[package.dependencies] +Django = ">=3.2" + [[package]] name = "django-filter" version = "23.5" @@ -4313,4 +4327,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.11.0" -content-hash = "0ce98116c9668c8216b8f3f7f181b3cbcf77bb26de50a9b8dcd22931d2ea4c6b" +content-hash = "e5620c30ca7cd18cd7d873c50725a83719fefb76d1edb4b22949fe7ba67f8bbd" diff --git a/pyproject.toml b/pyproject.toml index e9b7197d..6323a8f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,7 @@ djangorestframework-dataclasses = "^1.3.1" django-countries = "^7.6.1" mitol-django-geoip = ">=2024.11.05" py-moneyed = "^3.0" +django-extensions = "^3.2.3" [tool.poetry.group.dev.dependencies] bpython = "^0.24" diff --git a/unified_ecommerce/middleware.py b/unified_ecommerce/middleware.py index 91fe7568..64a77484 100644 --- a/unified_ecommerce/middleware.py +++ b/unified_ecommerce/middleware.py @@ -3,7 +3,7 @@ import logging from django.contrib.auth import login, logout -from django.contrib.auth.middleware import RemoteUserMiddleware +from django.contrib.auth.middleware import PersistentRemoteUserMiddleware from django.core.exceptions import ImproperlyConfigured from unified_ecommerce.utils import decode_apisix_headers, get_user_from_apisix_headers @@ -11,7 +11,7 @@ log = logging.getLogger(__name__) -class ApisixUserMiddleware(RemoteUserMiddleware): +class ApisixUserMiddleware(PersistentRemoteUserMiddleware): """Checks for and processes APISIX-specific headers.""" def process_request(self, request): @@ -28,6 +28,7 @@ def process_request(self, request): apisix_user = get_user_from_apisix_headers(request) except KeyError: if self.force_logout_if_no_header and request.user.is_authenticated: + log.debug("Forcing user logout due to missing APISIX headers.") logout(request) return None @@ -36,6 +37,9 @@ def process_request(self, request): # The user is authenticated, but doesn't match the user we got # from APISIX. So, log them out so the APISIX user takes # precedence. + log.debug( + "Forcing user logout because request user doesn't match APISIX user" + ) logout(request) diff --git a/unified_ecommerce/settings.py b/unified_ecommerce/settings.py index 36bca497..c89b10c6 100644 --- a/unified_ecommerce/settings.py +++ b/unified_ecommerce/settings.py @@ -94,6 +94,7 @@ "mitol.mail.apps.MailApp", "django_countries", "mitol.geoip", + "django_extensions", # Application modules "unified_ecommerce", "users", @@ -133,7 +134,7 @@ LOGIN_URL = "/login" LOGIN_ERROR_URL = "/login" LOGOUT_URL = "/logout" -LOGOUT_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/logged_out" ROOT_URLCONF = "unified_ecommerce.urls" @@ -497,6 +498,13 @@ name="MITOL_UE_FORCE_PROFILE_COUNTRY", default=False ) +MITOL_UE_PAYMENT_BASKET_ROOT = get_string( + name="MITOL_UE_PAYMENT_BASKET_ROOT", default="/cart/" +) +MITOL_UE_PAYMENT_BASKET_CHOOSER = get_string( + name="MITOL_UE_PAYMENT_BASKET_CHOOSER", default="/cart/" +) + import_settings_modules("mitol.payment_gateway.settings.cybersource") # Keycloak API settings diff --git a/unified_ecommerce/urls.py b/unified_ecommerce/urls.py index 47497d80..b1894848 100644 --- a/unified_ecommerce/urls.py +++ b/unified_ecommerce/urls.py @@ -23,7 +23,7 @@ urlpatterns = [ path("", include("cart.urls")), - path("", include("django.contrib.auth.urls")), + path("auth/", include("django.contrib.auth.urls")), path("admin/", admin.site.urls), path("hijack/", include("hijack.urls")), # OAuth2 Paths @@ -35,6 +35,7 @@ # API Paths re_path(r"", include("payments.urls")), re_path(r"", include("system_meta.urls")), + re_path(r"", include("users.urls")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: diff --git a/users/templates/logged_out.html b/users/templates/logged_out.html new file mode 100644 index 00000000..0e9347ac --- /dev/null +++ b/users/templates/logged_out.html @@ -0,0 +1,10 @@ + + + + Logged out + + + +

You're logged out.

+ + diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 00000000..5ec54b1d --- /dev/null +++ b/users/urls.py @@ -0,0 +1,31 @@ +"""Routes for the users app.""" + +from django.urls import include, re_path + +from users.views import ( + CurrentUserRetrieveViewSet, + LoggedOutView, + establish_session, +) + +v0_urls = [ + re_path( + r"^me/$", + CurrentUserRetrieveViewSet.as_view({"get": "retrieve"}), + name="current_user", + ), +] + +urlpatterns = [ + re_path( + r"^logged_out/$", + LoggedOutView.as_view(), + name="logged_out_page", + ), + re_path( + r"^establish_session/$", + establish_session, + name="users-establish_session", + ), + re_path(r"^api/v0/users/", include(v0_urls)), +] diff --git a/users/views.py b/users/views.py new file mode 100644 index 00000000..c3e2e0e6 --- /dev/null +++ b/users/views.py @@ -0,0 +1,52 @@ +"""Views for the users app.""" + +from django.conf import settings +from django.shortcuts import redirect +from django.views.generic import TemplateView +from rest_framework import mixins, viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAuthenticated + +from system_meta.models import IntegratedSystem +from unified_ecommerce.serializers import UserSerializer + + +class LoggedOutView(TemplateView): + """View for the logged out page.""" + + template_name = "logged_out.html" + + +class CurrentUserRetrieveViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): + """User retrieve and update viewsets for the current user""" + + serializer_class = UserSerializer + permission_classes = (AllowAny,) + + def get_object(self): + """Return the current request user""" + # NOTE: this may be a logged in or anonymous user + return self.request.user + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def establish_session(request): + """ + Establish a session, then redirect to the basket page. + + Set `next` to the integrated system you're working in, and the user will be + sent to the cart for that system afterwards. Otherwise, this will go to the + session check API endpoint. + """ + + if "next" in request.GET: + try: + system = IntegratedSystem.objects.get(slug=request.GET["next"]) + next_url = f"{settings.MITOL_UE_PAYMENT_BASKET_ROOT}{system.slug}/" + except IntegratedSystem.DoesNotExist: + next_url = settings.MITOL_UE_PAYMENT_BASKET_CHOOSER + + next_url = request.session.get("next", next_url) + + return redirect(next_url)