diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cc0b999f4001d28c50db65fd8e1a49be540967fa..e67e0936dd5cc89213d315645d2ef47f3c765c23 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,6 +36,7 @@ "libphonenumber-js": "^1.9.35", "lodash": "^4.17.21", "react": "^17.0.2", + "react-cookie": "^4.1.1", "react-dom": "^17.0.2", "react-helmet": "^6.1.0", "react-hook-form": "^7.17.5", @@ -4222,6 +4223,11 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/cookie": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", + "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" + }, "node_modules/@types/eslint": { "version": "7.28.2", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.28.2.tgz", @@ -4258,6 +4264,15 @@ "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz", "integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==" }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/html-minifier-terser": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz", @@ -18211,6 +18226,19 @@ "node": ">=10" } }, + "node_modules/react-cookie": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-4.1.1.tgz", + "integrity": "sha512-ffn7Y7G4bXiFbnE+dKhHhbP+b8I34mH9jqnm8Llhj89zF4nPxPutxHT1suUqMeCEhLDBI7InYwf1tpaSoK5w8A==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.0.1", + "hoist-non-react-statics": "^3.0.0", + "universal-cookie": "^4.0.0" + }, + "peerDependencies": { + "react": ">= 16.3.0" + } + }, "node_modules/react-dev-utils": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz", @@ -21857,6 +21885,15 @@ "node": ">=4" } }, + "node_modules/universal-cookie": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz", + "integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==", + "dependencies": { + "@types/cookie": "^0.3.3", + "cookie": "^0.4.0" + } + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -26973,6 +27010,11 @@ "@babel/types": "^7.3.0" } }, + "@types/cookie": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", + "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" + }, "@types/eslint": { "version": "7.28.2", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.28.2.tgz", @@ -27009,6 +27051,15 @@ "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz", "integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==" }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/html-minifier-terser": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz", @@ -37821,6 +37872,16 @@ "whatwg-fetch": "^3.4.1" } }, + "react-cookie": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-4.1.1.tgz", + "integrity": "sha512-ffn7Y7G4bXiFbnE+dKhHhbP+b8I34mH9jqnm8Llhj89zF4nPxPutxHT1suUqMeCEhLDBI7InYwf1tpaSoK5w8A==", + "requires": { + "@types/hoist-non-react-statics": "^3.0.1", + "hoist-non-react-statics": "^3.0.0", + "universal-cookie": "^4.0.0" + } + }, "react-dev-utils": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz", @@ -40693,6 +40754,15 @@ "crypto-random-string": "^1.0.0" } }, + "universal-cookie": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz", + "integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==", + "requires": { + "@types/cookie": "^0.3.3", + "cookie": "^0.4.0" + } + }, "universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 869252b77debd5493aaba95d2487a6a5d898b60a..6d3231eaec34e029ba288fd6d0cd58d054ea34d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "libphonenumber-js": "^1.9.35", "lodash": "^4.17.21", "react": "^17.0.2", + "react-cookie": "^4.1.1", "react-dom": "^17.0.2", "react-helmet": "^6.1.0", "react-hook-form": "^7.17.5", diff --git a/frontend/src/contexts/userContext.ts b/frontend/src/contexts/userContext.ts index 6fcc23f13aed02bfe0851de69151f29ae4cb38f8..ed0029453bb2f7ee438ed7338e42a8d442193dbe 100644 --- a/frontend/src/contexts/userContext.ts +++ b/frontend/src/contexts/userContext.ts @@ -12,6 +12,7 @@ function noop() {} export const UserContext = createContext<IUserContext>({ user: { auth: false, + auth_type: '', fetched: false, first_name: '', last_name: '', diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 9cd159ff4da87bee6e6eaa6a7544c8ad98e04bb5..ff8f15f2fd98049e1e14675df685966e4147f3d2 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -64,6 +64,7 @@ export type FetchedRole = { export interface User { auth: boolean + auth_type: string fetched: boolean person_id: string sponsor_id: string diff --git a/frontend/src/providers/userProvider.tsx b/frontend/src/providers/userProvider.tsx index 2d1d17a18fa6ccfb7f0841b0ef3699fef588f64c..3a17e9d8e231791ab137fa8685929ed0e55c9565 100644 --- a/frontend/src/providers/userProvider.tsx +++ b/frontend/src/providers/userProvider.tsx @@ -10,6 +10,7 @@ function UserProvider(props: UserProviderProps) { const { children } = props const [user, setUser] = useState({ auth: false, + auth_type: '', fetched: false, first_name: '', last_name: '', @@ -31,6 +32,7 @@ function UserProvider(props: UserProviderProps) { if (response.ok) { setUser({ auth: true, + auth_type: data.auth_type, fetched: true, first_name: data.first_name, last_name: data.last_name, @@ -46,6 +48,7 @@ function UserProvider(props: UserProviderProps) { } else { setUser({ auth: false, + auth_type: '', fetched: true, first_name: '', last_name: '', @@ -62,6 +65,7 @@ function UserProvider(props: UserProviderProps) { } catch (error) { setUser({ auth: false, + auth_type: '', fetched: true, first_name: '', last_name: '', @@ -92,6 +96,7 @@ function UserProvider(props: UserProviderProps) { const clearUserInfo = () => { setUser({ auth: false, + auth_type: '', fetched: false, first_name: '', last_name: '', diff --git a/frontend/src/routes/components/header.tsx b/frontend/src/routes/components/header.tsx index b69c4782854eaa0fd04a661f938d2f4d3d29bbbb..7b64b4233ae3c983d8300ef7cce52e2fcd325e66 100644 --- a/frontend/src/routes/components/header.tsx +++ b/frontend/src/routes/components/header.tsx @@ -48,6 +48,9 @@ const Header = () => { const { user } = useUserContext() const { t } = useTranslation('common') + const logoutLink = + user.auth_type === 'oidc' ? '/oidc/logout' : '/invite/logout' + return ( <StyledHeader> <MainContainer> @@ -61,7 +64,7 @@ const Header = () => { sx={{ color: 'white', }} - href="/oidc/logout" + href={logoutLink} > {t('button.logout')} <LogoutIcon diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index edb75c2226b1b231d03e68737cb53d251135004c..e273442a88500c9057310d7a663a4b068b2be86d 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -13,6 +13,7 @@ import Register from 'routes/sponsor/register' import FrontPage from 'routes/frontpage' import Invite from 'routes/invite' import InviteLink from 'routes/invitelink' +import LogoutInviteSession from 'routes/invitelink/logout' import Footer from 'routes/components/footer' import Header from 'routes/components/header' import NotFound from 'routes/components/notFound' @@ -77,6 +78,7 @@ export default function App() { <Register /> </ProtectedRoute> <Route path="/invitelink/" component={InviteLink} /> + <Route path="/invite/logout" component={LogoutInviteSession} /> <Route path="/invite/" component={Invite} /> <Route path="/guestregister" component={GuestRegister} /> <Route> diff --git a/frontend/src/routes/invitelink/logout.tsx b/frontend/src/routes/invitelink/logout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f0333dde0bfbb80a3d68fc6b5e3c9f8a7fd93ae1 --- /dev/null +++ b/frontend/src/routes/invitelink/logout.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react' +import { Redirect } from 'react-router-dom' +import { useCookies } from 'react-cookie' + +import { Box, CircularProgress } from '@mui/material' + +import { useUserContext } from 'contexts' + +export default function LogoutInviteSession() { + // Fetch backend endpoint to preserve invite_id in backend session then redirect + const [, , removeCookie] = useCookies(['sessionid']) + const [loggedOut, setLoggedOut] = useState(false) + const { fetchUserInfo } = useUserContext() + + useEffect(() => { + fetch('/api/ui/v1/invitecheck/', { method: 'DELETE' }) + .then(() => removeCookie('sessionid')) + .then(() => fetchUserInfo()) + .then(() => setLoggedOut(true)) + }, []) + + if (loggedOut) { + return <Redirect to="/" /> + } + + return ( + <Box sx={{ margin: 'auto' }}> + <CircularProgress /> + </Box> + ) +} diff --git a/gregui/api/views/invitation.py b/gregui/api/views/invitation.py index 2a36d024a488fcec3e01ec1d115bb9fd408143f3..a979d534b95357fe0a02a5e5ee7efb2275dda1a7 100644 --- a/gregui/api/views/invitation.py +++ b/gregui/api/views/invitation.py @@ -1,6 +1,6 @@ -import logging from enum import Enum from typing import Optional, List +import structlog from django.core import exceptions from django.db import transaction @@ -24,7 +24,7 @@ from gregui.api.serializers.invitation import InviteGuestSerializer from gregui.mailutils import send_invite_mail from gregui.models import GregUserProfile -logger = logging.getLogger(__name__) +logger = structlog.getLogger(__name__) class InvitationView(CreateAPIView, DestroyAPIView): @@ -91,9 +91,7 @@ class InvitationView(CreateAPIView, DestroyAPIView): # not be verified, but including that check just in case here. # If this is the case then there is an unexpected situation, the cancel option # should only apply to guests that have not completed the registration - logger.warning( - f"Attempting to delete invitation for already registered guest with person ID {person_id}" - ) + logger.warning("try_delete_registered_invite", person_id=person_id) return Response(status=status.HTTP_400_BAD_REQUEST) # Delete the person. The delete will cascade and all roles, identities and invitations will be removed. @@ -109,7 +107,7 @@ class CheckInvitationView(APIView): permission_classes = [AllowAny] throttle_classes = [AnonRateThrottle] - def post(self, request, *args, **kwargs): + def post(self, request, *args, **kwargs) -> Response: """ Endpoint for verifying and setting invite_id in session. @@ -132,6 +130,16 @@ class CheckInvitationView(APIView): request.session["invite_id"] = invite_id return Response(status=status.HTTP_200_OK) + def delete(self, request, *args, **kwargs) -> Response: + if "invite_id" in request.session: + logger.info( + "invitation_session_deleted", invite_id=request.session["invite_id"] + ) + del request.session["invite_id"] + return Response(status.HTTP_200_OK) + + return Response(status=status.HTTP_403_FORBIDDEN) + class SessionType(Enum): INVITE = "invite" @@ -322,9 +330,7 @@ class ResendInvitationView(UpdateModelMixin, APIView): if non_expired_links.count() > 0: if non_expired_links.count() > 1: # Do not expect this to happen - logger.warning( - f"Person with ID {person_id} has multiple invitation links" - ) + logger.warning("found_multiple_invitation_links", person_id=person_id) # Just resend all and do not create a new one for link in non_expired_links: @@ -339,9 +345,7 @@ class ResendInvitationView(UpdateModelMixin, APIView): # Do not expected that a person has several open invitations, it could happen # if he has been invited by different sponsor at the same time, but that # could be an indication that there has been a mixup - logger.warning( - f"Multiple invitations exist for person with ID {person_id}" - ) + logger.warning("found_multiple_invitations", person_id=person_id) for invitation in invitations_to_resend: invitation_link = InvitationLink.objects.create( diff --git a/gregui/api/views/userinfo.py b/gregui/api/views/userinfo.py index b2e17997d1c50815c546f824839438480e9f9425..bba967c519e0c611d7018bd651a47c35bd984d96 100644 --- a/gregui/api/views/userinfo.py +++ b/gregui/api/views/userinfo.py @@ -34,6 +34,10 @@ class UserInfoView(APIView): user = request.user invite_id = request.session.get("invite_id") + auth_type = "invite" + if "oidc_states" in request.session.keys(): + auth_type = "oidc" + person = None sponsor = None content = { @@ -41,6 +45,7 @@ class UserInfoView(APIView): "sponsor_id": None, "person_id": None, "roles": [], + "auth_type": auth_type, } # Fetch sponsor and/or person object from profile of authenticated user diff --git a/gregui/tests/api/views/test_userinfo.py b/gregui/tests/api/views/test_userinfo.py index 8b615661adab101b74bc49da74e4f19551b62b28..cf1f331d48c6ff3ca1fe2d2ec47f4db756ac7c68 100644 --- a/gregui/tests/api/views/test_userinfo.py +++ b/gregui/tests/api/views/test_userinfo.py @@ -19,6 +19,7 @@ def test_userinfo_invited_get(client, invitation_link): response = client.get(reverse("api-userinfo")) assert response.status_code == status.HTTP_200_OK assert response.json() == { + "auth_type": "invite", "feide_id": None, "sponsor_id": None, "person_id": 1, @@ -59,6 +60,7 @@ def test_userinfo_sponsor_get(client, log_in, user_sponsor): response = client.get(reverse("api-userinfo")) assert response.status_code == status.HTTP_200_OK assert response.json() == { + "auth_type": "oidc", "feide_id": "", "person_id": None, "roles": [], @@ -73,6 +75,7 @@ def test_userinfo_guest_get(client, log_in, user_person): response = client.get(reverse("api-userinfo")) assert response.status_code == status.HTTP_200_OK assert response.json() == { + "auth_type": "oidc", "feide_id": "", "sponsor_id": None, "person_id": 1, diff --git a/gregui/tests/conftest.py b/gregui/tests/conftest.py index c7e5d52ac930def1bce5b3ddde659a4b5891dcc4..8386752d53ad86292066851b8324c80d8b2e4a30 100644 --- a/gregui/tests/conftest.py +++ b/gregui/tests/conftest.py @@ -471,6 +471,7 @@ def log_in(client) -> Callable[[UserModel], APIClient]: # It seems like the session was not updated automatically this way session = client.session session["oidc_id_token_payload"] = {"iat": time.time()} + session["oidc_states"] = {} session.save() return client