Skip to content
Snippets Groups Projects
Commit 6e8ba6aa authored by Jonas Braathen's avatar Jonas Braathen
Browse files

Improve invitation link handling

- Combine the /invite and /invitelink endpoints into one
- Return error messages from the backend
- Handle errors in the frontend
- Lower number of queries ran when calling the userinfo endpoint
parent 834bc177
No related branches found
No related tags found
1 merge request!200Improve invitation link handling
Showing
with 274 additions and 92 deletions
......@@ -146,9 +146,11 @@
"cancelInvitationDescription": "Do you want to cancel the invitation?"
},
"error": {
"error": "Error",
"invitationCreationFailedHeader": "Failed to create invite",
"errorStatusCode": "Status code: {{statusCode}} (<3>{{statusText}}</3>)",
"genericServerErrorBody": "The server reported:<1>{{errorBodyText}}</1>",
"contactHelp": "Contact help through the link in the footer if the problem persists."
"contactHelp": "Contact help through the link in the footer if the problem persists.",
"unknown": "An unknown error has occurred. If the problem persists, contact support."
}
}
{
"description": "Please choose how you want to log in to complete your registration. The recommended way is to log in with either Feide or ID-porten. If that is not possible you can manually fill out the registration form with your passport number.",
"header": "Guest Registration",
"description": "Please choose how you want to log in to complete your registration. The recommended way is to log in with either Feide or ID-porten. If that is not possible you can manually fill out the registration form with your passport number or Norwegian national ID number.",
"header": "Guest registration",
"login": "Log in with FEIDE",
"manual": "Registrate manually"
"manual": "Register manually",
"errors": {
"invalidToken": "The invitation link you followed is invalid.",
"expiredToken": "The invitation link you followed has expired. Contact your host to get a new link."
}
}
......@@ -146,9 +146,11 @@
"cancelInvitationDescription": "Vil du kansellere invitasjonen?"
},
"error": {
"error": "Feil",
"invitationCreationFailedHeader": "Kunne ikke opprette invitasjon",
"errorStatusCode": "Statuskode: {{statusCode}} (<3>{{statusText}}</3>)",
"genericServerErrorBody": "Respons fra server:<1>{{errorBodyText}}</1>",
"contactHelp": "Kontakt hjelp via link i footer om problemet vedvarer."
"contactHelp": "Kontakt hjelp via link i footer om problemet vedvarer.",
"unknown": "En ukjent feil har oppstått. Om problemet vedvarer, kontakt brukerstøtte."
}
}
......@@ -2,5 +2,9 @@
"description": "Vennligst velg hvordan du vil logge inn for å fullføre registreringen. Den anbefalte metoden er å logge inn gjennom Feide eller ID-porten. Dersom det ikke er mulig kan du fylle ut registreringskjemaet manuelt med passnummer",
"header": "Gjestetjenesten",
"login": "Logg inn med FEIDE",
"manual": "Registrer manuelt"
"manual": "Registrer manuelt",
"errors": {
"invalidToken": "Denne invitasjonslenka er ugyldig.",
"expiredToken": "Denne invitasjonslenka er utløpt. Kontakt verten din for å få tilsendt en ny."
}
}
......@@ -147,9 +147,11 @@
"cancelInvitationDescription": "Vil du kansellere invitasjonen?"
},
"error": {
"error": "Feil",
"invitationCreationFailedHeader": "Kunne ikkje opprette invitasjon",
"errorStatusCode": "Statuskode: {{statusCode}} (<3>{{statusText}}</3>)",
"genericServerErrorBody": "Respons frå server:<1>{{errorBodyText}}</1>",
"contactHelp": "Kontakt hjelp via link i footer om problemet vedvarer."
"contactHelp": "Kontakt hjelp via link i footer om problemet vedvarer.",
"unknown": "Ein uventa feil oppstod. Om problemet varer ved, kontakt brukarstøtte."
}
}
......@@ -2,5 +2,9 @@
"description": "Ver venleg og vel korleis du vil logge inn for å fullføre registreringa. Den anbefalte metoden er å logge inn gjennom Feide eller ID-porten. Dersom det ikkje er mogeleg kan du fylle ut registreringskjemaet manuelt med passnummer",
"header": "Gjestetjenesten",
"login": "Logg inn med FEIDE",
"manual": "Registrer manuelt"
"manual": "Registrer manuelt",
"errors": {
"invalidToken": "Denne invitasjonslenka er ugyldig.",
"expiredToken": "Denne invitasjonslenka har utløpe. Kontakt verten din for å få tilsendt ei ny."
}
}
......@@ -5,24 +5,24 @@ import { styled } from '@mui/system'
import { CssBaseline } from '@mui/material'
import fetchIntercept from 'fetch-intercept'
import { registerLocale } from 'i18n-iso-countries'
import i18n_iso_countries_en from 'i18n-iso-countries/langs/en.json'
import i18n_iso_countries_nb from 'i18n-iso-countries/langs/nb.json'
import i18n_iso_countries_nn from 'i18n-iso-countries/langs/nn.json'
import { useUserContext } from 'contexts'
import { getCookie, deleteCookie } from 'utils'
import GuestRegister from 'routes/guest/register'
import Sponsor from 'routes/sponsor'
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 LogoutInviteSession from 'routes/invite/logout'
import Footer from 'routes/components/footer'
import Header from 'routes/components/header'
import NotFound from 'routes/components/notFound'
import ProtectedRoute from 'components/protectedRoute'
import { registerLocale } from 'i18n-iso-countries'
import i18n_iso_countries_en from 'i18n-iso-countries/langs/en.json'
import i18n_iso_countries_nb from 'i18n-iso-countries/langs/nb.json'
import i18n_iso_countries_nn from 'i18n-iso-countries/langs/nn.json'
import GuestRegister from './guest/register'
const AppWrapper = styled('div')({
display: 'flex',
......@@ -77,7 +77,6 @@ export default function App() {
<ProtectedRoute path="/register">
<Register />
</ProtectedRoute>
<Route path="/invitelink/" component={InviteLink} />
<Route path="/invite/logout" component={LogoutInviteSession} />
<Route path="/invite/" component={Invite} />
<Route path="/guestregister" component={GuestRegister} />
......
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Page from 'components/page'
import Loading from 'components/loading'
import { styled } from '@mui/material/styles'
import { useUserContext } from 'contexts'
import { HrefButton } from 'components/button'
import { HrefLineButton } from 'components/button/linebutton'
import { submitJsonOpts } from 'utils'
const FlexDiv = styled('div')(() => ({
display: 'flex',
gap: '0.5rem',
}))
function Invite() {
function ChooseRegistrationMethod() {
const { t } = useTranslation(['invite'])
return (
<Page>
<h1>{t('header')}</h1>
<p>
{t('description')}
</p>
<p>{t('description')}</p>
<FlexDiv>
<HrefButton to="/oidc/authenticate/">{t('login')}</HrefButton>
<HrefLineButton to="/guestregister/">{t('manual')}</HrefLineButton>
......@@ -28,4 +30,125 @@ function Invite() {
)
}
interface ShowFeedbackProps {
title: string
description: string
}
function ShowFeedback(props: ShowFeedbackProps) {
const { title, description } = props
return (
<Page>
<h1>{title}</h1>
<p>{description}</p>
</Page>
)
}
function Invite() {
const { t } = useTranslation(['invite'])
const { user, fetchUserInfo } = useUserContext()
const [inviteToken, setInviteToken] = useState('')
const [tokenChecked, setTokenChecked] = useState(false)
const [isCheckingToken, setIsCheckingToken] = useState(false)
const [tokenOk, setTokenOk] = useState(false)
const [checkError, setCheckError] = useState('')
async function checkToken(token: string) {
try {
const response = await fetch(
'/api/ui/v1/invitecheck/',
submitJsonOpts('POST', { invite_token: token })
)
if (response.status === 200) {
setTokenOk(true)
return
}
const data = await response.json()
if ('code' in data) {
setCheckError(data.code)
} else {
setCheckError('unknown')
}
} catch (error) {
console.error(error)
setCheckError('unknown')
} finally {
setTokenChecked(true)
setIsCheckingToken(false)
}
}
console.log({ inviteToken, isCheckingToken, tokenChecked, tokenOk, user })
useEffect(() => {
setIsCheckingToken(true)
// This may seem unecessary, but race conditions have been
// observed where the userinfo endpoint is called too fast
// and no invite_id is found in the server-side session
setTimeout(fetchUserInfo, 100)
}, [setTokenOk])
if (user.auth) {
return <ChooseRegistrationMethod />
}
if (isCheckingToken || (tokenOk && !user.auth)) {
return <Loading />
}
if (inviteToken !== '' && !tokenChecked) {
checkToken(inviteToken)
return <Loading />
}
if (checkError !== '') {
if (
checkError === 'missing_invite_token' ||
checkError === 'invalid_invite_token'
) {
// Missing or invalid token
return (
<ShowFeedback
title={t('common:error.error')}
description={t('invite:errors.invalidToken')}
/>
)
}
if (checkError === 'expired_invite_token') {
// Expired token
return (
<ShowFeedback
title={t('common:error.error')}
description={t('invite:errors.expiredToken')}
/>
)
}
// Unknown error
return (
<ShowFeedback
title={t('common:error.error')}
description={t('common:error.unknown')}
/>
)
}
const providedToken = window.location.hash.slice(1).trim()
if (!inviteToken && providedToken) {
setInviteToken(providedToken)
return <Loading />
}
// We'll end up here if no token was provided in the URL
return (
<ShowFeedback
title={t('common:error.error')}
description={t('invite:errors.invalidToken')}
/>
)
}
export default Invite
import { useEffect } from 'react'
import { Redirect } from 'react-router-dom'
import { submitJsonOpts, setCookie } from 'utils'
function InviteLink() {
// Fetch backend endpoint to preserve invite_id in backend session then redirect
// to generic invite page with info about feide login or manual with passport.
const id = window.location.hash.slice(1)
useEffect(() => {
fetch('/api/ui/v1/invitecheck/', submitJsonOpts('POST', { uuid: id }))
}, [])
setCookie('redirect', '/guestregister')
return <Redirect to="/invite" />
}
export default InviteLink
......@@ -118,15 +118,33 @@ class CheckInvitationView(APIView):
Uses post to prevent invite id from showing up in various logs.
"""
invite_id = request.data.get("uuid")
invite_id = request.data.get("invite_token")
if not invite_id:
return Response(status=status.HTTP_403_FORBIDDEN)
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={
"code": "missing_invite_token",
"message": "An invite token is required",
},
)
try:
invite_link = InvitationLink.objects.get(uuid=invite_id)
except (InvitationLink.DoesNotExist, exceptions.ValidationError):
return Response(status=status.HTTP_403_FORBIDDEN)
return Response(
status=status.HTTP_403_FORBIDDEN,
data={
"code": "invalid_invite_token",
"message": "Invite token is invalid",
},
)
if invite_link.expire <= timezone.now():
return Response(status=status.HTTP_403_FORBIDDEN)
return Response(
status=status.HTTP_403_FORBIDDEN,
data={
"code": "expired_invite_token",
"message": "Invite token has expired",
},
)
request.session["invite_id"] = invite_id
return Response(status=status.HTTP_200_OK)
......
......@@ -21,8 +21,8 @@ class UserInfoView(APIView):
Quick draft, we might want to expand this later.
"""
authentication_classes: Sequence[Type[BaseAuthentication]] = [SessionAuthentication]
permission_classes: Sequence[Type[BasePermission]] = [AllowAny]
authentication_classes = [SessionAuthentication]
permission_classes = [AllowAny]
def get(self, request, format=None):
"""
......@@ -32,6 +32,7 @@ class UserInfoView(APIView):
invitation id. Pure django users, and anonymous users are denied access.
"""
user = request.user
invite_id = request.session.get("invite_id")
auth_type = "invite"
......@@ -40,6 +41,8 @@ class UserInfoView(APIView):
person = None
sponsor = None
user_profile = None
content = {
"feide_id": None,
"sponsor_id": None,
......@@ -48,34 +51,57 @@ class UserInfoView(APIView):
"auth_type": auth_type,
}
# Fetch sponsor and/or person object from profile of authenticated user
if user.is_authenticated:
try:
user_profile = GregUserProfile.objects.get(user=user)
sponsor = user_profile.sponsor
person = user_profile.person
content.update(
{
"feide_id": user_profile.userid_feide,
}
)
user_profile = GregUserProfile.objects.select_related(
"person", "sponsor"
).get(user=user)
except GregUserProfile.DoesNotExist:
return Response(status=HTTP_403_FORBIDDEN)
return Response(
status=HTTP_403_FORBIDDEN,
data={
"code": "no_user_profile",
"message": "Authenticated, but missing user profile",
},
)
# Fetch sponsor and/or person object from profile of authenticated user
if user_profile:
sponsor = user_profile.sponsor
person = user_profile.person
content["feide_id"] = user_profile.userid_feide
# Or fetch person info for invited guest
elif invite_id:
link = InvitationLink.objects.get(uuid=invite_id)
link = InvitationLink.objects.select_related(
"invitation__role__person",
).get(uuid=invite_id)
person = link.invitation.role.person
# Otherwise, deny access
else:
return Response(status=HTTP_403_FORBIDDEN)
# Add sponsor fields if sponsor object present
if sponsor:
content.update({"sponsor_id": user_profile.sponsor.id})
# Add person fields if person object present
content["sponsor_id"] = sponsor.id
if person:
person_roles = [
{
"id": role.id,
"ou_nb": role.orgunit.name_nb,
"ou_en": role.orgunit.name_en,
"name_nb": role.type.name_nb,
"name_en": role.type.name_en,
"start_date": role.start_date,
"end_date": role.end_date,
"sponsor": {
"first_name": role.sponsor.first_name,
"last_name": role.sponsor.last_name,
},
}
for role in person.roles.all().select_related(
"orgunit", "type", "sponsor"
)
]
content.update(
{
"person_id": person.id,
......@@ -86,22 +112,7 @@ class UserInfoView(APIView):
and person.private_mobile.value,
"fnr": person.fnr and "".join((person.fnr.value[:-5], "*****")),
"passport": person.passport and person.passport.value,
"roles": [
{
"id": role.id,
"ou_nb": role.orgunit.name_nb,
"ou_en": role.orgunit.name_en,
"name_nb": role.type.name_nb,
"name_en": role.type.name_en,
"start_date": role.start_date,
"end_date": role.end_date,
"sponsor": {
"first_name": role.sponsor.first_name,
"last_name": role.sponsor.last_name,
},
}
for role in person.roles.all()
],
"roles": person_roles,
}
)
return Response(content)
......@@ -12,15 +12,21 @@ from greg.models import Consent, InvitationLink, Person, Identity
@pytest.mark.django_db
def test_post_invite(client):
"""Forbid access with bad invitation link uuid"""
response = client.post(reverse("gregui-v1:invite-verify"), data={"uuid": "baduuid"})
response = client.post(
reverse("gregui-v1:invite-verify"), data={"invite_token": "baduuid"}
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"code": "invalid_invite_token",
"message": "Invite token is invalid",
}
@pytest.mark.django_db
def test_post_invite_ok(client, invitation_link):
"""Access okay with valid invitation link"""
response = client.post(
reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid}
reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid}
)
assert response.status_code == status.HTTP_200_OK
......@@ -29,23 +35,34 @@ def test_post_invite_ok(client, invitation_link):
def test_post_invite_expired(client, invitation_link_expired):
"""Forbid access with expired invite link"""
response = client.post(
reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link_expired.uuid}
reverse("gregui-v1:invite-verify"),
data={"invite_token": invitation_link_expired.uuid},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"code": "expired_invite_token",
"message": "Invite token has expired",
}
@pytest.mark.django_db
def test_post_missing_invite_id(client):
"""Forbid access if no id provided."""
response = client.post(reverse("gregui-v1:invite-verify"))
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {
"code": "missing_invite_token",
"message": "An invite token is required",
}
@pytest.mark.django_db
def test_get_invited_info_no_session(client, invitation_link):
"""Forbid access if invite expired after session was created."""
# get a session
client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid})
client.post(
reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid}
)
# expire the invitation link
invitation_link.expire = timezone.now() - datetime.timedelta(days=1)
invitation_link.save()
......@@ -60,7 +77,9 @@ def test_get_invited_info_session_okay(
):
person, invitation_link = invited_person
# get a session
client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid})
client.post(
reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid}
)
# Get info
response = client.get(reverse("gregui-v1:invited-info"))
assert response.status_code == status.HTTP_200_OK
......@@ -95,7 +114,9 @@ def test_get_invited_info_expired_link(
client, invitation_link, invitation_expired_date
):
# Get a session while link is valid
client.get(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid})
client.get(
reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid}
)
# Set expire link to expire long ago
invlink = InvitationLink.objects.get(uuid=invitation_link.uuid)
invlink.expire = invitation_expired_date
......@@ -110,7 +131,9 @@ def test_get_invited_info_expired_link(
def test_invited_guest_can_post_information(client: APIClient, invited_person):
person, invitation_link = invited_person
# get a session
client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid})
client.post(
reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid}
)
person = invitation_link.invitation.role.person
assert person.private_mobile is None
......@@ -138,7 +161,9 @@ def test_post_invited_info_expired_session(
client, invitation_link, invitation_expired_date
):
# get a session
client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid})
client.post(
reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid}
)
# Set expire link to expire long ago
invlink = InvitationLink.objects.get(uuid=invitation_link.uuid)
invlink.expire = invitation_expired_date
......@@ -152,7 +177,9 @@ def test_post_invited_info_expired_session(
@pytest.mark.django_db
def test_post_invited_info_deleted_inv_link(client, invitation_link):
# get a session
client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid})
client.post(
reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid}
)
# Delete link
invlink = InvitationLink.objects.get(uuid=invitation_link.uuid)
invlink.delete()
......@@ -284,7 +311,9 @@ def test_name_update_not_allowed_if_feide_identity_is_present(
)
invitation = create_invitation(role)
invitation_link = create_invitation_link(invitation, invitation_valid_date)
client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid})
client.post(
reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid}
)
person = invitation_link.invitation.role.person
data = {
......@@ -330,7 +359,9 @@ def test_name_update_allowed_if_feide_identity_is_not_present(
invitation_link = create_invitation_link(
create_invitation(role), invitation_valid_date
)
client.post(reverse("gregui-v1:invite-verify"), data={"uuid": invitation_link.uuid})
client.post(
reverse("gregui-v1:invite-verify"), data={"invite_token": invitation_link.uuid}
)
person = invitation_link.invitation.role.person
data = {
......
......@@ -6,7 +6,7 @@ from rest_framework import status
@pytest.mark.django_db
def test_userinfo_anon_get(client):
"""Anonymous people should be forbidden."""
response = client.get(reverse("api-userinfo"))
response = client.get(reverse("gregui-v1:userinfo"))
assert response.status_code == status.HTTP_403_FORBIDDEN
......@@ -16,7 +16,7 @@ def test_userinfo_invited_get(client, invitation_link):
session = client.session
session["invite_id"] = str(invitation_link.uuid)
session.save()
response = client.get(reverse("api-userinfo"))
response = client.get(reverse("gregui-v1:userinfo"))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"auth_type": "invite",
......@@ -48,7 +48,7 @@ def test_userinfo_invited_get(client, invitation_link):
def test_userinfo_user_no_profile(client, log_in, user_no_profile):
"""Pure django users should be forbidden"""
log_in(user_no_profile)
response = client.get(reverse("api-userinfo"))
response = client.get(reverse("gregui-v1:userinfo"))
assert response.status_code == status.HTTP_403_FORBIDDEN
......@@ -57,7 +57,7 @@ def test_userinfo_sponsor_get(client, log_in, user_sponsor):
"""Sponsors should get info about themselves"""
log_in(user_sponsor)
response = client.get(reverse("api-userinfo"))
response = client.get(reverse("gregui-v1:userinfo"))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"auth_type": "oidc",
......@@ -72,7 +72,7 @@ def test_userinfo_sponsor_get(client, log_in, user_sponsor):
def test_userinfo_guest_get(client, log_in, user_person):
"""Logged in guests should get info about themself"""
log_in(user_person)
response = client.get(reverse("api-userinfo"))
response = client.get(reverse("gregui-v1:userinfo"))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"auth_type": "oidc",
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment