Přeskočit na hlavní obsah

JSON Web Token (JWT)

  • Způsob bezpečného přenosu informací mezi stranami ve formě JSON objektu
  • Informace lze ověřovat a důvěřovat jim, protože jsou digitálně podepsané serverem
    • Je možné zjistit, zda bylo s informacemi manipulováno
  • Lze je podepsat pomocí secret (HMAC) nebo private/public klíče (RSA)
  • Používá se při přenosu informací týkajících se identity a nároků / vlastností klienta
  • Token je vytvořen během přihlašování a ověřen serverem před jakýmkoli zpracováním
  • Server ověřuje platnost a integritu tokenu v bezstavové formě
  • Více informací najdete na oficiálních stránkách JWT

Struktura tokenu

  • [Base64(HEADER)].[Base64(PAYLOAD)].[Base64(SIGNATURE)]
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
{
"alg": "HS256",
"typ": "JWT"
}

Payload

{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}

Signature

HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), KEY )

Úvahy o používání JWT

  • Použití JWT je snadné a umožňuje vystavit služby bezstavovým způsobem (REST)
  • Není vhodný pro všechny aplikace, protože s sebou nese například otázku ukládání tokenu a další
  1. Zvažte použití sessions, pokud nepotřebujete, aby byla aplikace bezstavová
    • Session poskytují všechny webové frameworky
    1. Řiďte se radami z cheat sheetu pro správu session

Problémy

Hashing algoritmus None

Symptom

  • Nastane, když útočník změní token a algoritmus hashování tak, aby prostřednictvím klíčového slova none naznačil, že integrita tokenu už byla ověřena
  • Některé knihovny považovaly tokeny za podepsané algoritmem none (platné s ověřeným podpisem), čímž mohl útočník upravit token, který byl stále považován za důvěryhodný
  • Podívejte se na popis útoku

Jak tomu zabránit

  1. Použijte JWT knihovnu, která neobsahuje tuto zranitelnost
  2. Požadujte, aby byl použit očekávaný algoritmus při ověřování tokenu

Příklad implementace

// HMAC key - Block serialization and storage as String in JVM memory
private transient byte[] keyHMAC = ...;

...

// Create a verification context for the token requesting
// Explicitly the use of the HMAC-256 hashing algorithm
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC)).build();

// Verify the token, if the verification fail then a exception is thrown
DecodedJWT decodedToken = verifier.verify(token);

Token sidejacking

Symptom

  • Útočník zachytí / ukradne cizí token a použije ho k získání přístupu do aplikace

Jak tomu zabránit

  1. Přidejte do tokenu uživatelský kontext
    1. Vygenerujte náhodný řetězec během přihlašování a odešlete ho klientovi jako zabezpečený soubor cookie
    2. Vyhněte se nastavení hlavičky expires, aby se soubor vymazal při zavření prohlížeče
    3. Nastavte Max-Age na hodnotu menší nebo rovnu hodnotě vypršení platnosti JWT tokenu (nikdy ne větší)
    4. Uložte do tokenu vygenerovaný SHA-256 hash řetězce (zabrání XSS)
  2. Nepoužívejte IP adresy, protože existuje situace, kdy se může IP adresa během session změnit
    • Může také způsobit problémy s dodržováním GDPR
  3. Odmítněte přístup, pokud token neobsahuje správný kontext

Příklad implementace

  • Vytvoření tokenu po úspěšném přihlášení
// HMAC key - Block serialization and storage as String in JVM memory
private transient byte[] keyHMAC = ...;
// Random data generator
private SecureRandom secureRandom = new SecureRandom();

...

// Generate a random string that will constitute the fingerprint for this user
byte[] randomFgp = new byte[50];
secureRandom.nextBytes(randomFgp);
String userFingerprint = DatatypeConverter.printHexBinary(randomFgp);

// Add the fingerprint in a hardened cookie - Add cookie manually because
// SameSite attribute is not supported by javax.servlet.http.Cookie class
String fingerprintCookie = "__Secure-Fgp=" + userFingerprint
+ "; SameSite=Strict; HttpOnly; Secure";
response.addHeader("Set-Cookie", fingerprintCookie);

// Compute a SHA256 hash of the fingerprint in order to store the
// fingerprint hash (instead of the raw value) in the token
// to prevent an XSS to be able to read the fingerprint and
// set the expected cookie itself
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] userFingerprintDigest = digest.digest(userFingerprint.getBytes("utf-8"));
String userFingerprintHash = DatatypeConverter.printHexBinary(userFingerprintDigest);

// Create the token with a validity of 15 minutes and client context (fingerprint) information
Calendar c = Calendar.getInstance();
Date now = c.getTime();
c.add(Calendar.MINUTE, 15);
Date expirationDate = c.getTime();
Map<String, Object> headerClaims = new HashMap<>();
headerClaims.put("typ", "JWT");
String token = JWT.create().withSubject(login)
.withExpiresAt(expirationDate)
.withIssuer(this.issuerID)
.withIssuedAt(now)
.withNotBefore(now)
.withClaim("userFingerprint", userFingerprintHash)
.withHeader(headerClaims)
.sign(Algorithm.HMAC256(this.keyHMAC));
  • Validace tokenu
// HMAC key - Block serialization and storage as String in JVM memory
private transient byte[] keyHMAC = ...;

...

// Retrieve the user fingerprint from the dedicated cookie
String userFingerprint = null;
if (request.getCookies() != null && request.getCookies().length > 0) {
List<Cookie> cookies = Arrays.stream(request.getCookies()).collect(Collectors.toList());
Optional<Cookie> cookie = cookies.stream().filter(c -> "__Secure-Fgp"
.equals(c.getName())).findFirst();
if (cookie.isPresent()) {
userFingerprint = cookie.get().getValue();
}
}

// Compute a SHA256 hash of the received fingerprint in cookie in order to compare
// it to the fingerprint hash stored in the token
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] userFingerprintDigest = digest.digest(userFingerprint.getBytes("utf-8"));
String userFingerprintHash = DatatypeConverter.printHexBinary(userFingerprintDigest);

// Create a verification context for the token
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC))
.withIssuer(issuerID)
.withClaim("userFingerprint", userFingerprintHash)
.build();

// Verify the token, if the verification fail then an exception is thrown
DecodedJWT decodedToken = verifier.verify(token);

Žádný vestavěný mechanismus pro revoke (zneplatnění) tokenu

Symptom

  • Token se stává neplatným až po vypršení platnosti
  • Uživatel nemá k dispozici žádnou vestavěnou funkci pro explicitní zrušení platnosti tokenu
  • V případě krádeže není možné token revokovat

Jak tomu zabránit

  • JWT token je bezstavový = není udržována žádná session
  1. Implementujte stejnou ochranu jako pro Token sidejacking, protože cookie lze považovat za stejně bezpečnou jako session ID
  2. Implementujte seznam blokovaných tokenů (block list)
    1. Uchovávejte digest (zakódovaný SHA-256 v hex) s datem revokování
    2. Přidejte token do seznamu blokovaných tokenů, když se chce uživatel odhlásit
    • Záznam musí být platný nejméně do doby vypršení platnosti tokenu

Příklad implementace

  • Block list - databázová tabulka jako centrální úložiště
create table if not exists revoked_token(jwt_token_digest varchar(255) primary key,
revocation_date timestamp default now());
  • Správa revokování tokenu - přidání tokenu do block listu a kontrola, zda je revokovaný
/**
* Handle the revocation of the token (logout).
* Use a DB in order to allow multiple instances to check for revoked token
* and allow cleanup at centralized DB level.
*/
public class TokenRevoker {

/** DB Connection */
@Resource("jdbc/storeDS")
private DataSource storeDS;

/**
* Verify if a digest encoded in HEX of the ciphered token is present
* in the revocation table
*
* @param jwtInHex Token encoded in HEX
* @return Presence flag
* @throws Exception If any issue occur during communication with DB
*/
public boolean isTokenRevoked(String jwtInHex) throws Exception {
boolean tokenIsPresent = false;
if (jwtInHex != null && !jwtInHex.trim().isEmpty()) {
// Decode the ciphered token
byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);

// Compute a SHA256 of the ciphered token
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] cipheredTokenDigest = digest.digest(cipheredToken);
String jwtTokenDigestInHex = DatatypeConverter.printHexBinary(cipheredTokenDigest);

// Search token digest in HEX in DB
try (Connection con = this.storeDS.getConnection()) {
String query = "select jwt_token_digest from revoked_token where jwt_token_digest = ?";
try (PreparedStatement pStatement = con.prepareStatement(query)) {
pStatement.setString(1, jwtTokenDigestInHex);
try (ResultSet rSet = pStatement.executeQuery()) {
tokenIsPresent = rSet.next();
}
}
}
}

return tokenIsPresent;
}

/**
* Add a digest encoded in HEX of the ciphered token to the revocation token table
*
* @param jwtInHex Token encoded in HEX
* @throws Exception If any issue occur during communication with DB
*/
public void revokeToken(String jwtInHex) throws Exception {
if (jwtInHex != null && !jwtInHex.trim().isEmpty()) {
// Decode the ciphered token
byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);

// Compute a SHA256 of the ciphered token
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] cipheredTokenDigest = digest.digest(cipheredToken);
String jwtTokenDigestInHex = DatatypeConverter.printHexBinary(cipheredTokenDigest);

// Check if the token digest in HEX is already in the DB and add it if it is absent
if (!this.isTokenRevoked(jwtInHex)) {
try (Connection con = this.storeDS.getConnection()) {
String query = "insert into revoked_token(jwt_token_digest) values(?)";
int insertedRecordCount;
try (PreparedStatement pStatement = con.prepareStatement(query)) {
pStatement.setString(1, jwtTokenDigestInHex);
insertedRecordCount = pStatement.executeUpdate();
}
if (insertedRecordCount != 1) {
throw new IllegalStateException("Number of inserted record is invalid," +
" 1 expected but is " + insertedRecordCount);
}
}
}

}
}

Únik informací z tokenu

Symptom

  • Útočník ukradne a extrahuje informace uložené v tokenu (jejich obsah - zakódovaný v base64)
  • Token není ve výchozím nastavení šifrovaný
  • Získané informace mohou být: role, formát přihlášení, email

Jak tomu zabránit

  1. Zašifrujte token (např. pomocí symetrického algoritmu)
  2. Chraňte zašifrovaná data před Padding Oracle útokem nebo jiným typem využívajícím kryptoanalýzu
  3. Můžete použít AES-GSM algoritmus, který poskytuje autentizované šifrování
AEAD primitive (Authenticated Encryption with Associated Data) provides functionality of symmetric
authenticated encryption.

Implementations of this primitive are secure against adaptive chosen ciphertext attacks.

When encrypting a plaintext one can optionally provide associated data that should be authenticated
but not encrypted.

That is, the encryption with associated data ensures authenticity (ie. who the sender is) and
integrity (ie. data has not been tampered with) of that data, but not its secrecy.

See RFC5116: https://tools.ietf.org/html/rfc5116
  • Šifrování je přidáno kvůli skrytí citlivých informací
  • První ochranou proti manipulaci s tokenem je vždy podpis (signature) - dbejte na jeho ověření

Příklad implementace

  • Šifrování tokenu - použití knihovny Google Tink (využívá vestavěné osvědčené postupy)
/**
* Handle ciphering and deciphering of the token using AES-GCM.
*
* @see "https://github.com/google/tink/blob/master/docs/JAVA-HOWTO.md"
*/
public class TokenCipher {

/**
* Constructor - Register AEAD configuration
*
* @throws Exception If any issue occur during AEAD configuration registration
*/
public TokenCipher() throws Exception {
AeadConfig.register();
}

/**
* Cipher a JWT
*
* @param jwt Token to cipher
* @param keysetHandle Pointer to the keyset handle
* @return The ciphered version of the token encoded in HEX
* @throws Exception If any issue occur during token ciphering operation
*/
public String cipherToken(String jwt, KeysetHandle keysetHandle) throws Exception {
// Verify parameters
if (jwt == null || jwt.isEmpty() || keysetHandle == null) {
throw new IllegalArgumentException("Both parameters must be specified!");
}

// Get the primitive
Aead aead = AeadFactory.getPrimitive(keysetHandle);

// Cipher the token
byte[] cipheredToken = aead.encrypt(jwt.getBytes(), null);

return DatatypeConverter.printHexBinary(cipheredToken);
}

/**
* Decipher a JWT
*
* @param jwtInHex Token to decipher encoded in HEX
* @param keysetHandle Pointer to the keyset handle
* @return The token in clear text
* @throws Exception If any issue occur during token deciphering operation
*/
public String decipherToken(String jwtInHex, KeysetHandle keysetHandle) throws Exception {
// Verify parameters
if (jwtInHex == null || jwtInHex.isEmpty() || keysetHandle == null) {
throw new IllegalArgumentException("Both parameters must be specified !");
}

// Decode the ciphered token
byte[] cipheredToken = DatatypeConverter.parseHexBinary(jwtInHex);

// Get the primitive
Aead aead = AeadFactory.getPrimitive(keysetHandle);

// Decipher the token
byte[] decipheredToken = aead.decrypt(cipheredToken, null);

return new String(decipheredToken);
}
}
  • Tvorba a validace tokenu - načtení klíčů a nastavení šifry
// Load keys from configuration text/json files in order to avoid to storing keys as a String in JVM memory
private transient byte[] keyHMAC = Files.readAllBytes(Paths.get("src", "main", "conf", "key-hmac.txt"));
private transient KeysetHandle keyCiphering = CleartextKeysetHandle.read(JsonKeysetReader.withFile(
Paths.get("src", "main", "conf", "key-ciphering.json").toFile()));

...

// Init token ciphering handler
TokenCipher tokenCipher = new TokenCipher();
  • Tvorba tokenu
// Generate the JWT token using the JWT API...
// Cipher the token (String JSON representation)
String cipheredToken = tokenCipher.cipherToken(token, this.keyCiphering);
// Send the ciphered token encoded in HEX to the client in HTTP response...
  • Validace tokenu
// Retrieve the ciphered token encoded in HEX from the HTTP request...
// Decipher the token
String token = tokenCipher.decipherToken(cipheredToken, this.keyCiphering);
// Verify the token using the JWT API...
// Verify access ...

Ukládání tokenu na straně klienta

Symptom

  • K tomu dochází, když aplikace uloží token způsobem, který vykazuje následující:
    • Automatické odeslání prohlížečem (ukládání cookies)
    • Obnovení i při restartu prohlížeče (použití localStorage)
    • Získání tokenu v případě XSS (cookie přístupný javascriptu nebo token uložený v prohlížeči - local / session storage)

Jak tomu zabránit

  1. Uložte token do sessionStorage, nebo pomocí javascript closures s privátními proměnnými
    • Token je vystaven XSS útoku
  2. Přidejte HTTP hlavičku Authentication: Bearer při volání služeb
    • Alternativa k sessionStorage
    • Zapouzdří token do proměnné, ke které nelze přistupovat jinak než z daného modulu
  3. Přidejte k tomu fingerprint informaci
    • Zabraňuje opětovnému použití tokenu
  4. Dbejte zásad zabezpečení obsahu prohlížeče (Content Security Policy)
    • Mohou zabránit komunikaci s neočekávanými doménami
  5. Implementujte CSRF ochranu pokud token vydáváte v rámci zabezpečené cookie

Příklad implementace

/* Handle request for JWT token and local storage*/
function authenticate() {
const login = $("#login").val();
const postData = "login=" + encodeURIComponent(login) + "&password=test";

$.post("/services/authenticate", postData, function (data) {
if (data.status == "Authentication successful!") {
...
sessionStorage.setItem("token", data.token);
}
else {
...
sessionStorage.removeItem("token");
}
})
.fail(function (jqXHR, textStatus, error) {
...
sessionStorage.removeItem("token");
});
}
  • Přidání Authorization hlavičky při volání služby
/* Handle request for JWT token validation */
function validateToken() {
var token = sessionStorage.getItem("token");

if (token == undefined || token == "") {
$("#infoZone").removeClass();
$("#infoZone").addClass("alert alert-warning");
$("#infoZone").text("Obtain a JWT token first :)");
return;
}

$.ajax({
url: "/services/validate",
type: "POST",
beforeSend: function (xhr) {
xhr.setRequestHeader("Authorization", "bearer " + token);
},
success: function (data) {
...
},
error: function (jqXHR, textStatus, error) {
...
},
});
}
  • Implementace JS closures s privátními proměnnými
function myFetchModule() {
// Protect the original 'fetch' from getting overwritten via XSS
const fetch = window.fetch;

const authOrigins = ["https://yourorigin", "http://localhost"];
let token = '';

this.setToken = (value) => {
token = value
}

this.fetch = (resource, options) => {
let req = new Request(resource, options);
destOrigin = new URL(req.url).origin;
if (token && authOrigins.includes(destOrigin)) {
req.headers.set('Authorization', token);
}
return fetch(req)
}
}

...

// Usage:
const myFetch = new myFetchModule()

function login() {
fetch("/api/login")
.then((res) => {
if (res.status == 200) {
return res.json()
} else {
throw Error(res.statusText)
}
})
.then(data => {
myFetch.setToken(data.token)
console.log("Token received and stored.")
})
.catch(console.error)
}

...

// After login, subsequent api calls:
function makeRequest() {
myFetch.fetch("/api/hello", {headers: {"MyHeader": "foobar"}})
.then((res) => {
if (res.status == 200) {
return res.text()
} else {
throw Error(res.statusText)
}
}).then(responseText => console.log("helloResponse", responseText))
.catch(console.error)
}

Slabý secret

Symptom

  • Bezpečnost tokenu závisí na síle secretu, pokud je token chráněn pomocí algoritmu HMAC
  • Pokud útočník získá JWT, může se offline pokusit o prolomení secretu
  • Pokud by byl útočník úspěšný, může token upravit a podepsat získaným klíčem
    • To může umožnit zvýšení oprávnění, kompromitaci účtů nebo provádění jiných akcí v závislosti na obsahu JWT

Jak tomu zabránit

  1. Zajistěte, aby byl secret silný a jedinečný
  2. Zajistěte, aby měl secret alespoň 64 znaků a byl generován z bezpečného zdroje náhodnosti
  3. Zvažte použití tokenů podepsaných pomocí RSA

Kam dál