JWT и капаните му
Накратко: JWT е подписан токен от header, payload и подпис; payload-ът не е криптиран и се чете от всеки, а сървърът трябва сам да проверява алгоритъма, подписа и exp.
Проблем
Токенът, който стана admin
Мобилното приложение на магазина не ползва страници, а API. Екипът избира JWT, „за да не пази сървърът сесии“, и копира проверката на токена от стара статия в блог. Месеци по-късно изследовател на сигурността пише на магазина: с токен, който е написал на ръка, е влязъл в API-то за възстановяване на пари като admin.
Ключът на магазина не е изтекъл. Паролата на администратора също. Изследователят не е разбил нищо: проверката сама е приела токена му.
Идеята
Подписан пропуск
Сесията от предишния урок е номерче от гардероба: сървърът пази таблица кой е зад всяко id. JWT (JSON Web Token) е друг подход: самият токен казва кой си и до кога важи, а сървърът го подписва, за да не може да се промени.
Токенът има три части, разделени с точки:
- header: как е подписан, например
{"alg":"HS256","typ":"JWT"}; - payload: какво казва, например
{"sub":"maria","exp":1793000900}.subе потребителят,expе моментът на изтичане в секунди от 1.1.1970; - подпис: HMAC-SHA256 върху „header.payload“ с таен ключ на сървъра.
Header-ът и payload-ът са JSON, записан в base64url: base64 с - и _ вместо + и / и без = накрая, за да става за адрес и заглавка. Base64url е запис, не криптиране. Токенът е подписан, не таен: всеки го чете, но без ключа никой не може да го промени, без подписът да спре да съвпада.
Виж го
Трите части и подправеният токен
static String verify(String token, long now) { String[] parts = token.split("\\.", -1); if (parts.length != 3) return "✗ формат"; // алгоритъмът е закован: не го взимаме от токена String alg = text(decode(parts[0]), "alg"); if (!"HS256".equals(alg)) return "✗ алгоритъм"; String data = parts[0] + "." + parts[1]; if (!same(sign(data), parts[2])) return "✗ подпис"; String payload = decode(parts[1]); Long exp = number(payload, "exp"); if (exp == null || now >= exp) return "✗ изтекъл"; return "✓ " + text(payload, "sub");}static String issue(String user, long exp) { String header = """ {"alg":"HS256","typ":"JWT"}"""; String payload = """ {"sub":"%s","exp":%d}""".formatted(user, exp); String data = encode(header) + "." + encode(payload); return data + "." + sign(data);}// НЕ ТАКА: вярва на алгоритъма от самия токенstatic String naiveVerify(String token) { String[] parts = token.split("\\.", -1); String alg = text(decode(parts[0]), "alg"); if ("HS256".equals(alg)) { String data = parts[0] + "." + parts[1]; if (!sign(data).equals(parts[2])) return "✗ подпис"; } // "none": токенът сам казва, че няма подпис return "✓ " + text(decode(parts[1]), "sub");}static String verify(String token, long now) { String[] parts = token.split("\\.", -1); if (parts.length != 3) return "✗ формат"; // алгоритъмът е закован: не го взимаме от токена String alg = text(decode(parts[0]), "alg"); if (!"HS256".equals(alg)) return "✗ алгоритъм"; String data = parts[0] + "." + parts[1]; if (!same(sign(data), parts[2])) return "✗ подпис"; String payload = decode(parts[1]); Long exp = number(payload, "exp"); if (exp == null || now >= exp) return "✗ изтекъл"; return "✓ " + text(payload, "sub");}Стъпка по стъпка, с думи
- Header-ът казва как е подписан токенът: HS256. JSON-ът става текст в base64url.
- Payload-ът казва кой и до кога: sub maria, exp след 15 минути. Също base64url.
- Подписът е HMAC-SHA256 върху „header.payload“ с ключа на сървъра. Без ключа никой не може да го направи.
- Токенът е трите части с точки. Мобилното приложение го праща с всяка заявка.
- Който има токена, чете payload-а без ключ: base64url не е криптиране. Подписан е, не е таен.
- Сега подправен токен: header-ът казва alg none, payload-ът казва sub admin, а подпис няма.
- Наивната проверка пита токена кой алгоритъм да ползва. „none“ прескача подписа и admin влиза.
- Поправката заковава алгоритъма: само HS256, решено от сървъра. alg none е отказан преди всичко друго.
- Истински, но стар токен: подписът е верен, но exp е минал. Наивната проверка го пусна, тази не.
- Три проверки в този ред: алгоритъмът, избран от сървъра, подписът, срокът. Payload-ът никога не е тайна.
Подписът и base64url са в стандартната библиотека на всеки език. Ключът тук е демо: истинският е 32 случайни байта, пазени извън кода (урокът „Тайни“).
// демо ключ; истинският е 32 случайни байта от хранилище за тайниstatic final byte[] KEY = "demo-key-only-for-this-lesson".getBytes(UTF_8);static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
static String encode(String json) { return B64.encodeToString(json.getBytes(UTF_8));}
static String sign(String data) { try { Mac hmac = Mac.getInstance("HmacSHA256"); hmac.init(new SecretKeySpec(KEY, "HmacSHA256")); return B64.encodeToString(hmac.doFinal(data.getBytes(UTF_8))); } catch (GeneralSecurityException e) { throw new IllegalStateException(e); }}static String issue(String user, long exp) { String header = """ {"alg":"HS256","typ":"JWT"}"""; String payload = """ {"sub":"%s","exp":%d}""".formatted(user, exp); String data = encode(header) + "." + encode(payload); return data + "." + sign(data);}issue слепва двата JSON текста с text block и formatted. Това е безопасно само защото user е id от нашата база, без кавички. Истинският проект ползва библиотека, която прави и escape.Токен за Мария, който важи 15 минути:
long now = 1_793_000_000L;String token = issue("maria", now + 900);String[] parts = token.split("\\.");System.out.println("header: " + parts[0]);System.out.println("payload: " + parts[1]);System.out.println("подпис: " + parts[2]);System.out.println("payload без ключ: " + decode(parts[1]));header: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9payload: eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0подпис: mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKwpayload без ключ: {"sub":"maria","exp":1793000900}Ключът и „сега“ са фиксирани, затова токенът е един и същ при всяко пускане и на четирите езика. Последният ред е payload-ът, прочетен без ключа.
Пробвай
Предвиди изхода
Някой е хванал токена на Мария. Ключ няма. Какво ще отпечата?
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpYSIsImV4cCI6MTc5MzAwMDkwMH0.mXfW7XuQCRb8pXyoR6d687Dzn8zr5Y5uET-UFxEvOKw";String payload = token.split("\\.")[1];byte[] bytes = Base64.getUrlDecoder().decode(payload);System.out.println(new String(bytes, UTF_8));Средната част е само base64url. Декодира се без ключ и дава целия JSON, с exp. Затова в payload-а никога не се слагат тайни.
Идеята
Капан 1: алгоритъмът идва от токена
Ето проверката от статията. За да чете header и payload, тя ползва две помощни функции:
static String decode(String part) { return new String(Base64.getUrlDecoder().decode(part), UTF_8);}
// Java няма JSON в стандартната библиотека: за двете полета стига регулярен изразstatic String text(String json, String field) { Matcher m = Pattern.compile("\"" + field + "\":\"([^\"]*)\"").matcher(json); return m.find() ? m.group(1) : null;}
static Long number(String json, String field) { Matcher m = Pattern.compile("\"" + field + "\":(\\d+)").matcher(json); return m.find() ? Long.parseLong(m.group(1)) : null;}// НЕ ТАКА: вярва на алгоритъма от самия токенstatic String naiveVerify(String token) { String[] parts = token.split("\\.", -1); String alg = text(decode(parts[0]), "alg"); if ("HS256".equals(alg)) { String data = parts[0] + "." + parts[1]; if (!sign(data).equals(parts[2])) return "✗ подпис"; } // "none": токенът сам казва, че няма подпис return "✓ " + text(decode(parts[1]), "sub");}Тя чете alg от header-а и проверява подписа само ако там пише HS256. Стандартът наистина има алгоритъм none: „токен без подпис“. Ето четири токена: истинският, същият с подменен payload, ръчно написан токен с alg: none, sub: admin и празен подпис, и истински, но изтекъл токен:
long now = 1_793_000_000L;String good = issue("maria", now + 900);String[] p = good.split("\\.");String admin = """ {"sub":"admin","exp":%d}""".formatted(now + 900);// подменен payload, старият подписString changed = p[0] + "." + encode(admin) + "." + p[2];// alg: none и никакъв подписString none = encode(""" {"alg":"none","typ":"JWT"}""") + "." + encode(admin) + ".";String old = issue("maria", now - 60);System.out.println("истински: " + naiveVerify(good));System.out.println("подменен payload: " + naiveVerify(changed));System.out.println("alg: none: " + naiveVerify(none));System.out.println("изтекъл: " + naiveVerify(old));истински: ✓ mariaподменен payload: ✗ подписalg: none: ✓ adminизтекъл: ✓ mariaПодменен payload се хваща, защото подписът спира да съвпада. Но alg: none пуска admin без никакъв подпис, а изтеклият токен минава, защото exp не се гледа. Header-ът е вход от потребителя, като всичко в токена, докато подписът не е проверен (урокът „Недоверен вход“). Проверка, която пита токена как да го провери, вярва на атакуващия.
Виж го
Поправката: три проверки в ред
static String verify(String token, long now) { String[] parts = token.split("\\.", -1); if (parts.length != 3) return "✗ формат"; // алгоритъмът е закован: не го взимаме от токена String alg = text(decode(parts[0]), "alg"); if (!"HS256".equals(alg)) return "✗ алгоритъм"; String data = parts[0] + "." + parts[1]; if (!same(sign(data), parts[2])) return "✗ подпис"; String payload = decode(parts[1]); Long exp = number(payload, "exp"); if (exp == null || now >= exp) return "✗ изтекъл"; return "✓ " + text(payload, "sub");}static boolean same(String a, String b) { // сравнява всички байтове, без да спира при първата разлика return MessageDigest.isEqual(a.getBytes(UTF_8), b.getBytes(UTF_8));}- Алгоритъмът е закован. Сървърът знае, че подписва с HS256, и не приема нищо друго.
noneспира тук. - Подписът се сравнява за постоянно време, както CSRF токенът в предишния урок.
expе задължителен. Токен безexpили с миналexpе отказ.
Същите четири токена:
long now = 1_793_000_000L;String good = issue("maria", now + 900);String[] p = good.split("\\.");String admin = """ {"sub":"admin","exp":%d}""".formatted(now + 900);// подменен payload, старият подписString changed = p[0] + "." + encode(admin) + "." + p[2];// alg: none и никакъв подписString none = encode(""" {"alg":"none","typ":"JWT"}""") + "." + encode(admin) + ".";String old = issue("maria", now - 60);System.out.println("истински: " + verify(good, now));System.out.println("подменен payload: " + verify(changed, now));System.out.println("alg: none: " + verify(none, now));System.out.println("изтекъл: " + verify(old, now));истински: ✓ mariaподменен payload: ✗ подписalg: none: ✗ алгоритъмизтекъл: ✗ изтекълПробвай
Попълни
Кой алгоритъм приема проверката?
String alg = text(decode(parts[0]), "alg");
if (!"".equals(alg)) return "✗ алгоритъм";HS256: алгоритъмът, с който сървърът подписва. Той е в кода на сървъра, а не в токена.
Пробвай
Намери грешката
Тази проверка заковава HS256 и сравнява подписа правилно. Но една стара версия на приложението издаваше токени без exp, а те и днес работят. На кой ред е грешката?
Избери реда с грешката.
Срокът се проверява само ако exp го има. Токен без exp минава и важи вечно. Липсващият срок трябва да е отказ: проверката на сигурността отказва, когато се съмнява.
Идеята
Капан 2: къде стои токенът и как се отменя
localStorage. Уеб приложенията често пазят JWT в localStorage, защото е удобно. Но всеки скрипт на страницата чете localStorage. Една XSS дупка и чуждият скрипт праща токена на атакуващия, който го ползва до изтичането му. Подписът не помага: токенът не е подправен, а откраднат цял. В браузъра по-сигурното място е бисквитка с HttpOnly, но тогава тя тръгва сама и трябва защитата от CSRF от предишния урок.
Отмяна. Сървърът проверява само подписа и exp. Ако телефонът на Мария е откраднат, токенът ѝ работи до exp, дори да смени паролата си. Затова токените за достъп са кратки (5 до 15 минути), а дългият вход е с refresh токен, който сървърът пази и може да изтрие. Друг начин е списък с отменени токени на сървъра, но тогава сървърът пак пази състояние, както при сесията.
Кога сесията е по-проста. Ако имаш един уеб сайт с един сървър, бисквитката със сесия от предишния урок е по-простото решение: отменя се, като изтриеш реда, няма подпис за проверяване и няма алгоритъм, който да се обърка. JWT има смисъл, когато токена го проверяват много услуги без обща таблица със сесии, или между различни фирми (вход с Google е OpenID Connect, а той е JWT).
По-точно, за любопитните
Библиотеките. В истински проект JWT не се пише на ръка: Java има jjwt, java-jwt (Auth0) и Nimbus JOSE + JWT; C# има Microsoft.IdentityModel.JsonWebTokens, а ASP.NET Core проверява токените с AddJwtBearer; Python има PyJWT; Node има jsonwebtoken и jose. И с библиотека алгоритъмът се заковава изрично: jwt.decode(token, key, algorithms=["HS256"]) в PyJWT, jwt.verify(token, key, { algorithms: ["HS256"] }) в jsonwebtoken, ValidAlgorithms в TokenValidationParameters, verifyWith(key) в jjwt. Старите версии на някои библиотеки приемаха none, когато не им кажеш алгоритъма.
RS256 и ES256. С тях подписва частен ключ, а проверява публичен: така много услуги проверяват, без да могат да подписват. Тогава има и друг капан, объркване на алгоритъма: сървър, който очаква RS256, но пита токена, може да приеме HS256 с публичния ключ като HMAC ключ. Поправката е същата: закован алгоритъм.
Други полета. iat (кога е издаден), nbf (не по-рано от), aud (за коя услуга е), iss (кой го е издал), jti (уникално id, за отмяна). Часовниците на сървърите се разминават, затова библиотеките позволяват няколко секунди толеранс при exp.
Криптиран JWT има (JWE), но рядко трябва: по-просто е да не слагаш тайни в токена.
В урока Java чете JSON с регулярен израз, защото стандартната библиотека няма JSON. Това стига за нашите две полета, но не е парсер: истинската библиотека (Jackson вътре вjjwt) чете JSON правилно.Упражнение
Изход от всички устройства
Реши задачата в редактора си.
Мария губи телефона си и натиска „Изход от всички устройства“. Добави в payload-а jti, случайно уникално id на токена. Напиши logout, който записва jti на токена в множество от отменени, и проверка, която след verify отказва отменения токен с „✗ отменен“. Новият вход дава нов токен, който работи.
преди изхода: ✓ mariaслед изхода: ✗ отмененнов вход: ✓ mariaОще задачи
- Кратък живот. Направи токена за достъп 5 минути и добави refresh токен, който се пази на сървъра в таблица. Какво трябва да изтриеш при „Изход от всички устройства“ сега?
- Чистене. Отмененото
jtiтрябва да се пази само докато токенът и без това не изтече. Пази иexpи махай старите записи.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Мрежово програмиране · Недоверен вход · Пароли · Сесии, бисквитки и CSRF
- Продължава в: Криптографията, която не пишеш сам · Мащабиране
- Задачи по темата: Сигурност за програмисти