diff --git a/redaktions-app/src/backend/helper.ts b/redaktions-app/src/backend/helper.ts index 16e7bbe..6d47fb2 100644 --- a/redaktions-app/src/backend/helper.ts +++ b/redaktions-app/src/backend/helper.ts @@ -1,5 +1,6 @@ import {ApolloClient, createHttpLink, InMemoryCache} from "@apollo/client"; import {setContext} from "@apollo/client/link/context"; +import {getRawJsonWebToken} from "../jwt/jwt"; const httpLink = createHttpLink({ @@ -7,7 +8,7 @@ const httpLink = createHttpLink({ }); const authLink = setContext((_, { headers }) => { - const token = localStorage.getItem('token'); + const token = getRawJsonWebToken(); return token ? { headers: { ...headers, diff --git a/redaktions-app/src/components/CustomAppBar.tsx b/redaktions-app/src/components/CustomAppBar.tsx index 0f1a1d8..4f49cd2 100644 --- a/redaktions-app/src/components/CustomAppBar.tsx +++ b/redaktions-app/src/components/CustomAppBar.tsx @@ -29,7 +29,7 @@ function CustomAppBar() { const handleLogout = () => { localStorage.removeItem('token') - history.replace("/login") + history.push("/login") } const handleClose = () => { @@ -42,7 +42,7 @@ function CustomAppBar() { - Candymat Redaktion + Candymat
); +} + +describe('The main page', () => { + test('displays the editors page if an editor is logged in', () => { + const editorToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.kxdxmDrQw0vzD4tiXPj2fu-Cr8n7aWMikxntZ1ObF6c"; + localStorage.setItem("token", editorToken) + renderMainPage(); + + // it renders question and category lists + const questionListHeadline = screen.queryByText(/Fragen/); + const categoryListHeadline = screen.queryByText(/Kategorien/); + expect(questionListHeadline).not.toBeNull(); + expect(categoryListHeadline).not.toBeNull(); + }); + + test('displays the candidates page if a candidate is logged in', () => { + const candidateToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfY2FuZGlkYXRlIiwicGVyc29uX3Jvd19pZCI6MiwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.i66MDTPVWwfAvOawY25WE9OPb5CQ9hidoUruP91ngcg"; + localStorage.setItem("token", candidateToken) + renderMainPage(); + + const placeholder = screen.queryByText(/Under construction/); + expect(placeholder).not.toBeNull(); + }); + + test('displays the user page if an normal user is logged in', () => { + const userToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfcGVyc29uIiwicGVyc29uX3Jvd19pZCI6MywiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.RWo5USCmyn-OYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o"; + localStorage.setItem("token", userToken) + renderMainPage(); + + const placeholder = screen.queryByText(/nichts zu sehen/); + expect(placeholder).not.toBeNull(); + }); + + test('displays a link to the loggin page if something is wrong with the token', () => { + const invalidToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHOYjgYixq0y6qlObU9Rb0KdsxxvrtlW1o"; + localStorage.setItem("token", invalidToken) + renderMainPage(); + + const placeholder = screen.queryByRole("link", {name: /Login Seite/}); + expect(placeholder).not.toBeNull(); + }); +}); diff --git a/redaktions-app/src/components/Main.tsx b/redaktions-app/src/components/Main.tsx index 5a13d4e..8bc6604 100644 --- a/redaktions-app/src/components/Main.tsx +++ b/redaktions-app/src/components/Main.tsx @@ -1,10 +1,12 @@ import CustomAppBar from "./CustomAppBar"; -import {Container} from "@material-ui/core"; import React from "react"; import {makeStyles} from "@material-ui/core/styles"; -import {Copyright} from "./Copyright"; -import QuestionList from "./QuestionList"; -import CategoryList from "./CategoryList"; +import {MainPageEditor} from "./MainPageEditor"; +import {getJsonWebToken} from "../jwt/jwt"; +import {MainPageCandidate} from "./MainPageCandidate"; +import {MainPageUser} from "./MainPageUser"; +import {Link} from "react-router-dom"; +import {Container} from "@material-ui/core"; const useStyles = makeStyles((theme) => ({ appBarSpacer: theme.mixins.toolbar, @@ -13,32 +15,36 @@ const useStyles = makeStyles((theme) => ({ height: '100vh', overflow: 'auto', }, - container: { + invalidTokenContainer: { paddingTop: theme.spacing(4), paddingBottom: theme.spacing(4), - flexDirection: 'column', - }, - paper: { - margin: 5, - padding: theme.spacing(2), - display: 'flex', - overflow: 'auto', - flexDirection: 'column', - }, + } })); function Main() { const classes = useStyles(); + const getMainPage = () => { + switch (getJsonWebToken()?.role) { + case "candymat_editor": + return ; + case "candymat_candidate": + return ; + case "candymat_person": + return ; + default: + localStorage.removeItem('token'); + return + Du bist nicht eingelogged oder dein Token ist ungültig. Logge dich erneut ein.
+ Zur Login Seite +
+ } + } return (
- - - - - + {getMainPage()}
) diff --git a/redaktions-app/src/components/MainPageCandidate.tsx b/redaktions-app/src/components/MainPageCandidate.tsx new file mode 100644 index 0000000..c03f919 --- /dev/null +++ b/redaktions-app/src/components/MainPageCandidate.tsx @@ -0,0 +1,21 @@ +import {Container} from "@material-ui/core"; +import React from "react"; +import {makeStyles} from "@material-ui/core/styles"; + +const useStyles = makeStyles((theme) => ({ + container: { + paddingTop: theme.spacing(4), + paddingBottom: theme.spacing(4), + flexDirection: 'column', + }, +})); + +export function MainPageCandidate() { + const classes = useStyles(); + + return ( + + Under construction + + ); +} diff --git a/redaktions-app/src/components/MainPageEditor.tsx b/redaktions-app/src/components/MainPageEditor.tsx new file mode 100644 index 0000000..ae0e6db --- /dev/null +++ b/redaktions-app/src/components/MainPageEditor.tsx @@ -0,0 +1,26 @@ +import {Container} from "@material-ui/core"; +import QuestionList from "./QuestionList"; +import CategoryList from "./CategoryList"; +import {Copyright} from "./Copyright"; +import React from "react"; +import {makeStyles} from "@material-ui/core/styles"; + +const useStyles = makeStyles((theme) => ({ + container: { + paddingTop: theme.spacing(4), + paddingBottom: theme.spacing(4), + flexDirection: 'column', + }, +})); + +export function MainPageEditor() { + const classes = useStyles(); + + return ( + + + + + + ); +} diff --git a/redaktions-app/src/components/MainPageUser.tsx b/redaktions-app/src/components/MainPageUser.tsx new file mode 100644 index 0000000..01afac5 --- /dev/null +++ b/redaktions-app/src/components/MainPageUser.tsx @@ -0,0 +1,21 @@ +import {Container} from "@material-ui/core"; +import React from "react"; +import {makeStyles} from "@material-ui/core/styles"; + +const useStyles = makeStyles((theme) => ({ + container: { + paddingTop: theme.spacing(4), + paddingBottom: theme.spacing(4), + flexDirection: 'column', + }, +})); + +export function MainPageUser() { + const classes = useStyles(); + + return ( + + Sorry, für dich gibt es hier leider nichts zu sehen... + + ); +} diff --git a/redaktions-app/src/jwt/jwt.test.ts b/redaktions-app/src/jwt/jwt.test.ts new file mode 100644 index 0000000..396eed7 --- /dev/null +++ b/redaktions-app/src/jwt/jwt.test.ts @@ -0,0 +1,36 @@ +import {parseJwt} from "./jwt"; + +describe("The parseJwt function", () => { + test("parses a valid candymat jwt", () => { + const validJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6MSwiZXhwIjoxNjA5NDEyMTY4LCJpYXQiOjE2MDkyMzkzNjgsImF1ZCI6InBvc3RncmFwaGlsZSIsImlzcyI6InBvc3RncmFwaGlsZSJ9.6VLDBS5_HwgWIf_MKkMCuj4EVBZkbSGm87aWt5grP2M"; + + const jwt = parseJwt(validJwt) + + expect(jwt).not.toBeNull(); + expect(jwt?.person_row_id).toBe(1); + expect(jwt?.role).toBe("candymat_editor"); + }); + + test("returns null if role claim is invalid", () => { + const invalidRoleClaimJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHllZGl0b3IiLCJwZXJzb25fcm93X2lkIjoxLCJleHAiOjE2MDk0MTIxNjgsImlhdCI6MTYwOTIzOTM2OCwiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0._AVFTMqMkIuyrfQGTmWE-Qi-C72KCrZ3s_uVyfuEDco"; + + const jwt = parseJwt(invalidRoleClaimJwt); + + expect(jwt).toBeNull(); + }) + + test("returns null if person_row_id is not a number", () => { + const invalidRowIdClaimJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfZWRpdG9yIiwicGVyc29uX3Jvd19pZCI6IjEiLCJleHAiOjE2MDk0MTIxNjgsImlhdCI6MTYwOTIzOTM2OCwiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0.NfXylzN44qrZA5DX0qxxU71vJ1o9gdunscnK6V193Fc"; + + const jwt = parseJwt(invalidRowIdClaimJwt); + + expect(jwt).toBeNull(); + }) + + test("returns null if token is rubish.", () => { + const brokenJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eysssssssssssssssssssss.6VLDBS5_HwgWIf_MKkMCuj4EVBZkbSGm87aWt5grP2M"; + const jwt = parseJwt(brokenJwt); + + expect(jwt).toBeNull(); + }) +}); diff --git a/redaktions-app/src/jwt/jwt.ts b/redaktions-app/src/jwt/jwt.ts new file mode 100644 index 0000000..85f7801 --- /dev/null +++ b/redaktions-app/src/jwt/jwt.ts @@ -0,0 +1,48 @@ +export const getRawJsonWebToken = (): string | null => { + return localStorage.getItem('token'); +} + +export const getJsonWebToken = (): JwtPayload | null => { + const rawToken = getRawJsonWebToken(); + return rawToken ? parseJwt(rawToken) : null +} + +export const parseJwt = (token: string): JwtPayload | null => { + try { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ); + const jwtPayload = JSON.parse(jsonPayload); + return isJwtPayloadValid(jwtPayload) ? jwtPayload : null + } catch { + return null + } +} + +export const isJwtPayloadValid = (jwtPayload: JwtPayload): boolean => { + return claims.every(claim => Object.keys(jwtPayload).includes(claim)) + && userRoles.includes(jwtPayload.role) + && typeof (jwtPayload.person_row_id) === 'number' + && typeof (jwtPayload.exp) === 'number' + && typeof (jwtPayload.iat) === 'number'; +} + +const claims = ["role", "person_row_id", "exp", "iat", "aud", "iss"] +const userRoles = ["candymat_editor", 'candymat_candidate', 'candymat_person'] + +interface JwtPayload { + "role": UserRole, + "person_row_id": number, + "exp": number, + "iat": number, + "aud": "postgraphile", + "iss": "postgraphile" +} + +type UserRole = "candymat_editor" | 'candymat_candidate' | 'candymat_person' +