Les 7: REST APIs & JWT

Sebastiaan Henauseptember 17, 2022Ongeveer 6 minuten

Les 7: REST APIs & JWT

We hebben tijdens de vorige twee lessen gebruik gemaakt van Supabase om data in te laden. Alhoewel deze tool, en vergelijkbare services, eenvoudig zijn in gebruik, wordt er toch heel frequent gekozen voor een eigen ontwikkelde back-end.

Een eigen back-end is flexibeler en kan geschreven worden in de talen waarin een bedrijf al gespecialiseerd is. Of een bestaande back-end kan gebruikt worden als basis voor een nieuwe React applicatie.

De communicatie tussen back-end en front-end gebeurt in de meeste gevallen via een REST of GraphQL APIopen in new window, hoe zo'n APIs geschreven worden ligt buiten de scope van deze cursus. We bespreken wel hoe deze APIs beveiligd kunnen worden en hoe we een REST API kunnen raadplegen vanuit een React App.

Startbestandenopen in new window

De API is geschreven is geschreven in .NET 6, als je deze SDK nog niet geïnstalleerd hebt, kan je deze hieropen in new window downloaden. De API is beveiligd met een gebruikersnaam en wachtwoord, deze zijn:

REST API

Een REST (representational state transfer) API is een architecturaal patroon voor web APIs. Het is geen framework, programmeertaal, standaard, of protocol. Dit betekent dat de API geïmplementeerd kan worden in elke programmeertaal. Verder zijn er ook geen limieten op de structuur van de teruggegeven data. Deze kan als JSON, HTML, Plain Text, XML, ... teruggegeven worden. Als is JSON veruit het meest populaire formaat.

Een REST api is stateless, dit betekent dat de server niet weet welke data een client heeft en wat de client ermee doet.

De API die we voor deze les gebruiken heeft volgende structuur:

http://localhost:4000/api/*

De * in deze url duid een resource aan, dit kan één van de volgende drie dingen zijn:

REST Requests

Voor elke resource zijn CRUD-operaties beschikbaar. Met elke CRUD-operatie komt een HTTP-methode overeen.

CRUD-operatieHTTP-methode
CreatePOST
ReadGET
UpdatePUT
DeleteDELETE

Elk request naar de API bestaat uit vier zaken:

  1. Operatie: Een HTTP-methode.
  2. Endpoint: Het laatste deel van URL, voor onze API dus /api/*.
  3. Body: Parameters, data die naar de API verstuurd wordt.
  4. Header: HTTP-headers die zaken zoals authentication data bevatten.

Afhankelijk van de methode worden er parameter toegevoegd aan het endpoint of worden er parameters toegevoegd. Een parameter wordt achteraan de URL toegevoegd, bijvoorbeeld /api/robots/1. Hier is 1 de paramter.

CRUD-operatieHTTP-methodeParamterBody
CreatePOSTGeen parameterDe data voor het nieuwe object, geen ID toevoegen.
parameter --> alles ophalen

Parameter --> één specifiek object ophalen
/Update
vereistDe nieuwe data voor het object dat geüpdatet wordt.Delete

JSON Web Token

Een JSON Web Token (JWT) is een standaard voor het doorgeven van data tussen twee of meerdere partijen. Deze data is digitaal gehandtekend en kan dus geverifieerd kan worden. Daarbovenop kan een JWT ook geëncrypteerd worden, al is dit veel zeldzamer en heeft die enkel nut als de JWT gebruikt wordt om te communiceren tussen servers.

Naast het uitwisselen van data, kan een JWT ook gebruikt worden voor authenticatie. Als een gebruiker inlogt, krijgt deze een JWT terug van de server. Als de client een verzoek naar de server stuurt, wordt deze JWT toegevoegd aan het request (in de headers). De server verifieert de claims in de token en weet zo wie de gebruiker is. Let op, een JWT kan enkel gebruikt worden voor authentication, het identificeren van een account. Maar niet voor authorization, het bepalen van de rechten van een account. Dit moet de server zelf nog verifiëren.

Structuur

De structuur van een JWT bestaat uit 3 delen, die van elkaar gescheiden worden door een punt.

  1. Header
  2. Payload
  3. Signature

Deze structuur is herkenbaar in onderstaande JWT (newline toegevoegd voor de duidelijkheid).

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9<strong>.</strong><br/>
eyJpZCI6IjEiLCJuYmYiOjE2Mzk0MTg5NjQsImV4cCI6MTYzOTQxOTI2NCwiaWF0IjoxNjM5NDE4OTY0fQ<strong>.</strong><br/>
hSrVEbaFOUAA5B1oxrdSwPK7qp4wWCjiACYi3MKGLy8

Deze data is encoded in base64, als we het tweede, en voor ons belangrijkste, element decoderen, krijgen we volgende informatie te zien.

{
  "id": "1",
  "nbf": 1639418964,
  "exp": 1639419264,
  "iat": 1639418964
}



 


In deze data, is alles behalve het id, een geregistreerde claim. Dit betekent dat er een vaste betekenis achter zit. De nbf claim geeft weer vanaf wanneer de JWT geldig is. De exp claim geeft weer tot wanneer de token geldig is. In ons geval zal dit steeds 10 minuten na de uitgavedatum zijn. Maar deze termijn ligt niet vast. De iat claim geeft weer wanneer de JWT uitgegeven is.

Refreshing a JWT

We zien in bovenstaande data duidelijk dat de JWT vervalt, dit betekent dat we JWT slechts enkele minuten kunnen gebruiken. Daarna vervalt de JWT en is de gebruiker niet meer ingelogd.

Natuurlijk willen we niet dat de gebruiker elke 10 minuten opnieuw moet inloggen. Daarom wordt gebruik gemaakt van een refresh token. Deze refresh token kan gebruikt worden om een nieuwe JWT aan te vragen zonder dat de gebruiker opnieuw moet inloggen. De refresh token heeft echter ook een gelimiteerde houdbaarheidsdatum. Deze is wel een pak langer dan die van een JWT.

Om het proces zo veilig mogelijk te maken, wordt deze bewaard in een cookie dat niet aangepast kan worden vanuit JavaScript. Je kan de cookies voor een website bekijken via de developer console, via het "Storage" tabblad.

Figuur 1: Refresh Cookie

Onderstaande afbeelding demonstreert de werking van een JWT refresh token.

Figuur 2: Refresh Cookie

Bron: Supertokens.io - 13/12/21open in new window

React applicatie

Onderstaande code demonstreert hoe we een JWT kunnen gebruiken in een React app. We gebruiken hiervoor de Robot API. We focussen on slechts op enkel kritieke delen, al de andere React code bevat geen enkele nieuwigheid.

User API calls

Om in te loggen, moeten we data versturen naar de API, dit gebeurt met de POST methode. Dus hebben we data nodig in de body van het request. Deze data wordt doorgegeven als tweede argument van de axios.post functie (lijnen 5-8). Merk op dat we de withCredentials parameter toevoegen aan het request (lijn 9), dit is vereist om de cookies die teruggegeven worden door de server te bewaren.

/src/api/authenticationAPI.js
// Enkel de relevante methode getoond.
export const login = async (username, password) => {
    const {data, request} = await axios.post(
        '/users/authenticate',
        {
            username,
            password
        },
        { withCredentials: true }
    )

    if (request.status === 200) {
        return data;
    }

    return undefined;
}




 
 
 
 
 








Vervolgens hebben we natuurlijk ook een methode nodig om de JWT te verversen. Hier moeten we het refresh cookie doorsturen met het request. Hiervoor kunnen we de withCredentials parameter gebruiken in de configuratie van een axios call (lijn 7). Merk op dat we nog steeds een tweede (data) parameter moeten toevoegen aan de axios.post functie, ook al moet er geen data meegeven worden.

/src/api/authenticationAPI.js
// Enkel de relevante methode getoond.
export const refreshToken = async () => {
    try {
        const {request, data} = await axios.post(
            '/users/refresh-token',
            {},
            { withCredentials: true }
        )

        if (request.status === 200) {
            return data;
        }
    } catch (error) {
        console.log("Couldn't refresh the JWT, user will have to relog.");
        return undefined;
    }
    return undefined;
}






 











useJWT hook

De startbestanden bevatten al een atoom waar we de gebruikersdata en de huidige JWT kunnen bewaren.

/src/recoilState/usersState.js
export const userData = atom({
    key: 'userData',
    default: undefined
})

export const jwtToken = selector({
    key: 'jwtToken',
    get: ({get}) => {
        const user = get(userData);
        return user ? user['jwtToken'] : undefined;
    }
})

Vervolgens kunnen we een hook schrijven die we op het rootniveau van de applicatie kunnen oproepen. Deze hook kunnen we gebruiken om de JWT-token te refreshen nadat de applicatie opgestart word en nadat de timeout periode vervallen is.

Voor we aan de hook beginnen voegen we eerst onderstaande methode toe, waarmee een JWT geparset kan worden. Op lijn 2 splitsen we de JWT op, zodat we enkel de payload overhouden. Vervolgens gebruiken we de atob methode om de base64 data om te decoderen. Vervolgens kan deze gedecodeerde data omgevormd worden tot een JavaScript object.

/src/hooks/useJWT.js
const parseJWTPayload = (jwt) => {
    const jwtPayload = jwt.split('.')[1];
    return JSON.parse(window.atob(jwtPayload));
}

Vervolgens kunnen we in de useJWT hook een useEffect hook gebruiken om een nieuwe timeout toe te voegen die 1 minuut voor de expiration date, de JWT refresht (lijnen 8-21). Merk op dat we de timeout annuleren in de cleanup-functie.

/src/hooks/useJWT.js
// parseJWTPayload en imports zijn weggelaten.

const useJWT = () => {
    const [triedRefresh, setTriedRefresh] = useState(false);
    const jwt = useRecoilValue(jwtToken);
    const setUser = useSetRecoilState(userData);

    useEffect(() => {
        if (!jwt) return;
        
        const expirationDate = new Date(parseJWTPayload(jwt).exp * 1000);
        const timeDelta = expirationDate - Date.now();
        const timeout = timeDelta - (60 * 1000);
        
        const timeoutId = window.setTimeout(async () => {
            const user = await refreshToken();
            setUser(user);
        }, timeout);

        return () => {if (timeoutId) window.clearTimeout(timeoutId);}
    }, [jwt, setUser])
}







 
 
 
 
 
 
 
 
 
 
 
 
 
 

Tenslotte voegen we een tweede useEffect hook toe waarmee we, na het eerste opstarten van de applicatie, de JWT verversen.

/src/hooks/useJWT.js
// parseJWTPayload en imports zijn weggelaten.

const useJWT = () => {
    const [triedRefresh, setTriedRefresh] = useState(false);
    const jwt = useRecoilValue(jwtToken);
    const setUser = useSetRecoilState(userData);

    // Eerste useEffect weggelaten.

    useEffect(() => {
        if (triedRefresh) return;
        if (jwt) setTriedRefresh(true);

        const refresh = async () => {
            const user = await refreshToken();
            setUser(user);
            setTriedRefresh(true);
        }
        refresh();
    }, [triedRefresh, jwt, setUser])
}









 
 
 
 
 
 
 
 
 
 
 

De hook kan tenslotte opgeroepen worden in de App component.

/src/app.js
// Niet relevante code weggelaten.
const App = () => {
    useJWT();

    return (
        <BrowserRouter>
            <NavBar/>
            <Container>
                <Main/>
            </Container>
        </BrowserRouter>
    );
};


 










Gebruik van een JWT

Tenslotte moeten we nog methodes voorzien om een robot te updaten, aan te maken of te verwijderen. Deze methodes hebben allemaal dezelfde structuur als de voorgaande axios methodes. Het enige verschil is dat we de JWT moeten meegeven in de headers van het request. De JWT wordt bewaard in de Authentication header en heeft de structuur 'Bearer jwtToken'.

Voor een UPDATE of DELETE moet het id van de robot die bewerkt moet worden aangegeven worden in URL, zoals in bovenstaande tabel te zien was. De tabel geeft ook aan dat we de informatie over de robot die geüpdatet of aangemaakt moet worden moeten meegeven in de body van het request. Let op, dit moet als een JSON-object, dus gebruiken we de spread operator om de attributen van een bestaand object te kopiëren.

/src/api/robots.js
export const updateRobot = async (bearer, robot) => {
    const {request} = await axios.put(
        `robots/${robot.id}`,
        {
            ...robot
        },
        {
            headers: {
                "Authorization": `Bearer ${bearer}`
            }
        }
    )
    return request.status === 200;
}

export const createRobot = async (bearer, robot) => {
    const {request, data} = await axios.post(
        '/robots',
        {
            ...robot
        },
        {
            headers: {
                "Authorization": `Bearer ${bearer}`
            }
        }
    )
    if (request.status === 200) {
        return data;
    }
    return undefined;
}

export const deleteRobot = async (bearer, id) => {
    const {request} = await axios.delete(
        `/robots/${id}`,
        {
            headers: {
                "Authorization": `Bearer ${bearer}`
            }
        }
    )

    return request.status === 200;
}







 
 
 












 
 
 












 
 
 





Uitgewerkt lesvoorbeeld