Logo Light
Published on

Autentizace uživatele pomocí JWT

Jak probíhá autentizace uživatele pro přístup k API pomocí JWT?

JWT (JSON Web Token) je jedním ze způsobů, jak provádět autentizaci na webu. Server, ke kterému se chci připojovat, vytvoří token, který potvrzuje, že je uživatel přihlášen. Tento token se uživateli uloží do prohlížeče a při každém dalším požadavku se posílá zpět na server. Server se podívá na token a ověří, že je pravý a platný. Uživatel se proto nemusí při každém požadavku stále dokola přihlašovat.

Stručný popis celého procesu

Proces popíšu pro jednoduchý případ, kdy uživatel posílá data přes formulář (frontend v Reactu), který komunikuje se serverem přes RESTful API (backend v Expressu s knihovnou bcrypt):

Frontend - ReactBackend - Express + bcrypt
Uživatel vyplní registrační formulář (přihlašovací jméno + heslo) - tím odešle POST request na API endpoint pro registraci (např. /register)Knihovnou bcrypt se zahešuje heslo a uloží se do databáze
Uživatel teď může vyplnit přihlašovací formulář - tím odešle POST request na API endpoint pro přihlášení (např. /login)
  • Podle přihlašovacího jména se najde uživatel.
  • Pomocí knihovny bcrypt se porovná odeslané heslo s uloženým zahešovaným heslem.
  • Jestliže je heslo ok, vytvoří se JWT a jako odpověď se pošle zpět klientovi (do prohlížeče).
V prohlížeči se JWT uloží a může se od teď používat v autorizační hlavičce pro další requesty, a tím se autentizovat.Při každém dalším požadavku API middleware nejdříve ověří, zda je JWT platný, než umožní pokračování zpracování požadavku.

Ukázka implementace

  1. Registrace

    i. Frontend

    Na endpoint /register posílám při registraci POST requestem přihlašovací jméno (v našem případě email) a heslo:

    const handleRegister = async () => {
      await fetch("https://server-api.com/register", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          email: "joe@example.com",
          password: "heslo123",
        }),
      });
    };
    

    ii. Backend

    V Expressu definuju endpoint, na kterém zpracuju request a data uložím do databáze. Heslo před uložením "osolím" a zahešuju (jak funguje solení v bcrypt bude vysvětleno později).

    const bcrypt = require("bcrypt");
    
    app.post("/register", async (req, res) => {
      const { email, password } = req.body;
      const hashedPassword = await bcrypt.hash(password, 10);
      const newUser = { id: uuid(), email: email, password: hashedPassword };
    
      // Místo pro uložení nového uživatele s přihlašovacími údaji do db
    
      res.status(201).json({ message: "Uživatel úspěšně zaregistrován" });
    });
    
  2. Přihlášení uživatele

    i. Frontend

    Na endpoint /login posílám při přihlášení POST requestem přihlašovací jméno (v našem případě email) a heslo. V odpovědi mi přijde JWT, který uložím v prohlížeči.

    const handleLogin = async () => {
      const res = await fetch("https://server-api.com/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          email: "joe@example.com",
          password: "heslo123",
        }),
      });
    
      const data = await res.json();
      localStorage.setItem("token", data.token); // Uložení JWT
    };
    

    ii. Backend

    Podle emailu vyhledá v db uživatele. Porovná zahešované heslo z databáze se zadaným heslem při přihlašování. Jako odpověď vrátí JWT. K vytvářenému JWT se připojí JWT_SECRET pro ověření při zpracování následujících requestů (jak funguje JWT_SECRET bude vysvětleno později).

    const jwt = require("jsonwebtoken");
    const JWT_SECRET = "tajný-klíč";
    
    app.post("/login", async (req, res) => {
      const { email, password } = req.body;
    
      const user = db.findUserByEmail(email);
      if (!user) return res.status(400).json({ message: "Uživatel nebyl nalezen" });
    
      const passwordMatch = await bcrypt.compare(password, user.password);
      if (!passwordMatch) return res.status(403).json({ message: "Chybné heslo" });
    
      const token = jwt.sign({ id: user.id, name: user.name }, JWT_SECRET, {
        expiresIn: "1h",
      });
    
      res.json({ token }); // Odešle JWT na frontend
    });
    
    
  3. Volání zabezpečených služeb

    i. Frontend

    Pro přístup k zabezpečeným službám musím v hlavičce requestu použít získaný JWT.

    const token = localStorage.getItem("token");
    
    fetch("https://your-api.com/protected", {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    })
      .then((res) => res.json())
      .then((data) => console.log(data));
    

    ii. Backend Middleware

    Funkce authenticateToken ověří, jestli je JWT v hlavičce requestu validní. Použiju jí potom jako parametr při definici zabezpečených služeb (viz následující příklad).

    const authenticateToken = (req, res, next) => {
      const authHeader = req.headers['authorization']
      const token = authHeader && authHeader.split(' ')[1]
      if (!token) return res.status(401).json({ message: 'No token provided' })
    
      jwt.verify(token, JWT_SECRET, (err, user) => {
        if (err) return res.status(403).json({ message: 'Invalid token' })
        req.user = user
        next()
      })
    }
    

    iii. Backend - zabezpečená služba

    Funkce authenticateToken z předchozího bodu je přidaná jako parametr do specifikace služby:

    app.get('/protected', authenticateToken, (req, res) => {
      res.json({ message: `Autentizace proběhla ok` })
    })
    

Jak fungeje solení a hešování v bcrypt?

Sůl je náhodný řetězec, který se přidá k heslu před zahešováním. Kdyby dva uživatelé měli stejné heslo, díky osolení mi vzniknou různé hashe.

Jak vypadá výsledek hešovací funkce bcrypt?

Tento kód const hash = await bcrypt.hash("mypassword", 10); přiřadí do proměnné hash něco takového:
$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
Hash se skládá z několika částí oddělených symbolem $.
Struktura je: $typ_algoritmu$cost_factor$sůl+hash

V našem případě:

  1. typ algoritmu je 2b
  2. cost factor (náklady na vytvoření - viz vysvětlení později) je 10
  3. sůl je N9qo8uLOickgx2ZMRZoMye (22 znaků)
  4. samotný hash je IjZAgcfl7p92ldGxad68LJZdL17lhWy (31 znaků)

Když potom zavolám bcrypt.compare("mypassword", "$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"), bcrypt si z druhého argumentu vytáhne sůl, cost factor a samotný hash a použije je na vytvoření hashe z hesla, s kterým se uživatel pokouší přihlásit. Pak tyto dva hashe porovná a tím zjistí, jestli je přihlašovací heslo ok.

Co je to cost factor?

Cost factor 10 znamená, že bcrypt vykonná 2^10 opakování hashovacího procesu. Na začátku spojí sůl a heslo do jednoho řetězce a pak na něj aplikuje 2^10 krát proces, který je označován jako Expensive Key Schedule. Zvyšuje tím tak časovou náročnost na zahešování hesla, ale i na jeho zpětné prolomení.

Struktura tokenu

JWT má tři části oddělené tečkami: header.payload.signature

  1. header: obsahuje použitý algoritmus (v našem případě HS256) a typ tokenu (JWT)
  2. payload: může obsahovat různá data, jako např. iduživatele, expirace tokenu apod.
  3. signature: samotný podpis

Jak funguje podepisování tokenu?

Při vytváření JWT se token "podepisuje", čímž se zajišťuje jeho pravost. V zásadě jsou dva typy algoritmů, které můžeme k podepsání použít:

  1. HS256 (algoritmus typu HMAC - symetrický algoritmus) Pro podepisování i ověření se používá jeden společný klíč (náš JWT_SECRET). Je to soukromý klíč (musí být udržen v tajnosti).
  2. RS256 nebo ES256 (asymetrické algoritmy) Používá dvojici soukromý a veřejný klíč. Soukromý je určený pro podepisování tokenu, veřejný pro verifikaci (může být sdílen). U našeho případu by byl JWT_SECRET nahrazen soukromým klíčem při podepisování. Ověřování by probíhalo pomocí veřejného klíče v jwt.verify. Tento způsob se častěji používá v prostředí s více službami (např. mikroservisy), kde potřebujeme bezpečně ověřovat tokeny mezi různými servery.

Podepsání tokenu step-by-step

Zavolám funkci jwt.sign(payload, secret, options) která vytvoří JWT v těchto krocích:

  1. Vytvoření hlavičky

    Vytvoří JSON objekt jako např.:

{
  "alg": "HS256",
  "typ": "JWT"
}
  1. Zakódování hlavičky a payloadu

    Payload může vypadat např. takto:

{
  "userId": 123,
  "exp": 1712345678
}

Pro kódování je použité base64, které je URL-safe.

  1. Vygenerování podpisu Kombinací hešovací funkce s tajným šifrovacím klíčem (v našem případě JWT_SECRET) se vytvoří podpis (signature): signature = HMAC_SHA256(base64Url(header) + "." + base64Url(payload), secret)

  2. Spojení všech tří částí do JWT header.payload.signature

Ověření tokenu step-by-step

Zavolám funkci jwt.verify(token, secret)

  1. Rozdělení tokenu

Token je rozdělený na header, payload a signature

  1. Přegenerování podpisu

Z hlavičky a payloadu se znuvu vypočítá podpis (signature) s použitím šifrovacího klíče (parametr secret ve funkci verify - v našem případě JWT_SECRET)

  1. Porovnání podpisů (signature)

Pokud vypočítaný podpis odpovídá podpisu v tokenu, tak je poslaný token ok.

  1. Ověření časové platnosti

Pokud má token časovou platnost, tak se ověří, jestli nevypršela.

  1. Return payload

Pokud je vše ok, funkce vrací dekódovaný payload z tokenu.