Pengesahan Java yang berlebihan dengan Token Web JSON (JWT)

Bersedia untuk membina, atau berjuang dengan, pengesahan yang selamat dalam aplikasi Java anda? Tidak pasti faedah menggunakan token (dan khususnya token web JSON), atau bagaimana ia harus digunakan? Saya teruja untuk menjawab soalan-soalan ini, dan banyak lagi, untuk anda dalam tutorial ini!

Sebelum kita menyelami JSON Web Tokens (JWT), dan perpustakaan JJWT (dibuat oleh Stormpath's CTO, Les Hazlewood dan dikendalikan oleh komuniti penyumbang), mari kita bahas beberapa asas.

1. Pengesahan vs Pengesahan Token

Kumpulan protokol yang digunakan aplikasi untuk mengesahkan identiti pengguna adalah pengesahan. Aplikasi mempunyai identiti secara tradisional melalui kuki sesi. Paradigma ini bergantung pada penyimpanan ID sesi di sisi pelayan yang memaksa pemaju untuk membuat penyimpanan sesi yang unik dan khusus pelayan, atau dilaksanakan sebagai lapisan penyimpanan sesi yang sama sekali terpisah.

Pengesahan token dikembangkan untuk menyelesaikan masalah ID sesi sisi pelayan tidak, dan tidak dapat. Sama seperti pengesahan tradisional, pengguna menunjukkan bukti kelayakan yang dapat disahkan, tetapi kini dikeluarkan satu set token dan bukannya ID sesi. Kelayakan awal boleh menjadi pasangan nama pengguna / kata laluan standard, kunci API, atau bahkan token dari perkhidmatan lain. (Ciri Pengesahan Kunci API Stormpath adalah contohnya.)

1.1. Mengapa Token?

Secara sederhana, menggunakan token sebagai ganti ID sesi dapat menurunkan beban pelayan anda, memperkemas pengurusan izin, dan menyediakan alat yang lebih baik untuk menyokong infrastruktur yang diedarkan atau berasaskan awan. Dalam kes JWT, ini dapat dicapai terutamanya melalui sifat token jenis token ini (lebih banyak lagi di bawah).

Token menawarkan pelbagai aplikasi, termasuk: Skema perlindungan Cross Site Request Forgery (CSRF), interaksi OAuth 2.0, ID sesi, dan (dalam kuki) sebagai representasi pengesahan. Dalam kebanyakan kes, standard tidak menetapkan format tertentu untuk token. Berikut adalah contoh token CSRF Spring Security khas dalam bentuk HTML:

Sekiranya anda cuba menghantar borang itu tanpa token CSRF yang betul, anda mendapat jawapan ralat, dan itulah utiliti token. Contoh di atas adalah token "bodoh". Ini bermaksud tidak ada makna yang melekat dari token itu sendiri. Di sinilah JWT membuat perbezaan besar.

2. Apa yang ada dalam JWT?

JWT (diucapkan "jots") adalah rentetan selamat URL, dikodkan, ditandatangani kriptografi (kadang-kadang dienkripsi) yang dapat digunakan sebagai token dalam berbagai aplikasi. Berikut adalah contoh JWT digunakan sebagai token CSRF:

Dalam kes ini, anda dapat melihat bahawa token lebih panjang daripada contoh sebelumnya. Seperti yang kami lihat sebelumnya, jika borang dihantar tanpa token, anda akan mendapat jawapan ralat.

Jadi, mengapa JWT?

Token di atas ditandatangani secara kriptografi dan oleh itu dapat disahkan, memberikan bukti bahawa ia belum diubah. JWT juga dikodkan dengan pelbagai maklumat tambahan.

Mari lihat anatomi JWT untuk lebih memahami bagaimana kita memerah semua kebaikan ini. Anda mungkin menyedari bahawa terdapat tiga bahagian berbeza yang dipisahkan oleh titik ( .):

Kepala eyJhbGciOiJIUzI1NiJ9
Muatan eyJqdGkiOiJlNjc4ZjIzMzQ3ZTM0MTBkYjdlNjg3Njc4MjNiMmQ3MCIsImlhdC

I6MTQ2NjYzMzMxNywibmJmIjoxNDY2NjMzMzE3LCJleHAiOjE0NjY2MzY5MTd9

Tandatangan rgx_o8VQGuDa2AqCHSgVOD5G68Ld_YYM7N7THmvLIKc

Setiap bahagian dikodkan URL base64. Ini memastikan bahawa ia dapat digunakan dengan selamat dalam URL (lebih lanjut mengenai ini kemudian). Mari kita perhatikan lebih dekat setiap bahagian secara berasingan.

2.1. Pengepala

Sekiranya anda base64 untuk menyahkod header, anda akan mendapat rentetan JSON berikut:

{"alg":"HS256"}

Ini menunjukkan bahawa JWT ditandatangani dengan HMAC menggunakan SHA-256.

2.2. Muatan

Sekiranya anda menyahkod muatan, anda mendapat rentetan JSON berikut (diformat untuk kejelasan):

{ "jti": "e678f23347e3410db7e68767823b2d70", "iat": 1466633317, "nbf": 1466633317, "exp": 1466636917 }

Dalam muatan, seperti yang anda lihat, ada sejumlah kunci dengan nilai. Kunci-kunci ini disebut "tuntutan" dan spesifikasi JWT mempunyai tujuh yang dinyatakan sebagai tuntutan "berdaftar". Mereka adalah:

penerbitan Pengeluar
sub Subjek
aud Penonton
luput Tamat tempoh
nbf Bukan Sebelum
iat Dikeluarkan pada
jti ID JWT

Semasa membina JWT, anda boleh mengemukakan tuntutan khusus yang anda mahukan. Senarai di atas hanya mewakili tuntutan yang disediakan baik dalam kunci yang digunakan dan jenis yang diharapkan. CSRF kami mempunyai ID JWT, waktu "Dikeluarkan Pada", waktu "Tidak Sebelum", dan waktu Tamat. Masa tamat tepat satu minit setelah masa dikeluarkan.

2.3. Tandatangan

Akhirnya, bahagian tandatangan dibuat dengan mengambil header dan muatan bersama (dengan. Di antara) dan menyebarkannya melalui algoritma yang ditentukan (HMAC menggunakan SHA-256, dalam kes ini) bersama dengan rahsia yang diketahui. Perhatikan bahawa rahsia itu selalu merupakan array byte, dan mestilah panjang yang masuk akal untuk algoritma yang digunakan. Di bawah ini, saya menggunakan rentetan pengkodan base64 rawak (untuk kebolehbacaan) yang ditukar menjadi tatasusunan bait.

Nampaknya seperti ini dalam pseudo-code:

computeHMACSHA256( header + "." + payload, base64DecodeToByteArray("4pE8z3PBoHjnV1AhvGk+e8h2p+ShZpOnpr8cwHmMh1w=") )

Selagi anda mengetahui rahsianya, anda boleh membuat tandatangan itu sendiri dan membandingkan hasilnya dengan bahagian tandatangan JWT untuk mengesahkan bahawa ia belum diubah. Secara teknikal, JWT yang ditandatangani secara kriptografi disebut JWS. JWT juga boleh dienkripsi dan kemudian akan disebut JWE. (Dalam praktik sebenarnya, istilah JWT digunakan untuk menggambarkan JWE dan JWS.)

Ini membawa kita kembali kepada manfaat menggunakan JWT sebagai token CSRF kami. Kami dapat mengesahkan tandatangan dan kami dapat menggunakan maklumat yang dikodkan dalam JWT untuk mengesahkan kesahihannya. Oleh itu, bukan hanya perwakilan rentetan JWT yang sesuai dengan apa yang disimpan di sisi pelayan, kita juga dapat memastikan bahawa ia tidak akan habis dengan memeriksa tuntutan exp . Ini menjimatkan pelayan daripada mengekalkan keadaan tambahan.

Baiklah, kita telah membuat banyak perkara di sini. Mari selami beberapa kod!

3. Sediakan Tutorial JJWT

JJWT (//github.com/jwtk/jjwt) adalah perpustakaan Java yang menyediakan pembuatan dan pengesahan Token Web JSON hujung-ke-hujung. Selamanya percuma dan sumber terbuka (Apache License, Versi 2.0), ia dirancang dengan antara muka yang berfokus pada pembangun yang menyembunyikan sebahagian besar kerumitannya.

Operasi utama dalam menggunakan JJWT melibatkan pembinaan dan penghuraian JWT. Kami akan melihat operasi ini seterusnya, kemudian melihat beberapa ciri JJWT yang diperluas, dan akhirnya, kami akan melihat JWT beraksi sebagai token CSRF dalam aplikasi Spring Security, Spring Boot.

Kod yang ditunjukkan dalam bahagian berikut boleh didapati di sini. Catatan: Projek ini menggunakan Spring Boot sejak awal kerana mudah berinteraksi dengan API yang dipaparkannya.

Untuk membina projek, laksanakan perkara berikut:

git clone //github.com/eugenp/tutorials.git cd tutorials/jjwt mvn clean install

Salah satu perkara hebat mengenai Spring Boot adalah betapa mudahnya menjalankan aplikasi. Untuk menjalankan aplikasi JJWT Fun, lakukan perkara berikut:

java -jar target/*.jar 

Terdapat sepuluh titik akhir yang dinyatakan dalam aplikasi contoh ini (saya menggunakan httpie untuk berinteraksi dengan aplikasi tersebut. Ia boleh didapati di sini.)

http localhost:8080
Available commands (assumes httpie - //github.com/jkbrzt/httpie): http //localhost:8080/ This usage message http //localhost:8080/static-builder build JWT from hardcoded claims http POST //localhost:8080/dynamic-builder-general claim-1=value-1 ... [claim-n=value-n] build JWT from passed in claims (using general claims map) http POST //localhost:8080/dynamic-builder-specific claim-1=value-1 ... [claim-n=value-n] build JWT from passed in claims (using specific claims methods) http POST //localhost:8080/dynamic-builder-compress claim-1=value-1 ... [claim-n=value-n] build DEFLATE compressed JWT from passed in claims http //localhost:8080/parser?jwt= Parse passed in JWT http //localhost:8080/parser-enforce?jwt= Parse passed in JWT enforcing the 'iss' registered claim and the 'hasMotorcycle' custom claim http //localhost:8080/get-secrets Show the signing keys currently in use. http //localhost:8080/refresh-secrets Generate new signing keys and show them. http POST //localhost:8080/set-secrets HS256=base64-encoded-value HS384=base64-encoded-value HS512=base64-encoded-value Explicitly set secrets to use in the application.

Pada bahagian yang mengikuti, kami akan memeriksa setiap titik akhir ini dan kod JJWT yang terdapat dalam pengendali.

4. Membina JWT Dengan JJWT

Kerana antara muka JJWT yang lancar, penciptaan JWT pada dasarnya adalah proses tiga langkah:

  1. Definisi tuntutan dalaman token, seperti Penerbit, Subjek, Tamat Tempoh, dan ID.
  2. Penandatanganan kriptografi JWT (menjadikannya JWS).
  3. Pemadatan JWT ke rentetan selamat URL, mengikut peraturan Serialisasi Kompak JWT.

JWT akhir akan menjadi rentetan yang dikodkan base64 tiga bahagian, ditandatangani dengan algoritma tandatangan yang ditentukan, dan menggunakan kunci yang disediakan. Selepas titik ini, token siap untuk dikongsi dengan pihak lain.

Berikut adalah contoh JJWT dalam tindakan:

String jws = Jwts.builder() .setIssuer("Stormpath") .setSubject("msilverman") .claim("name", "Micah Silverman") .claim("scope", "admins") // Fri Jun 24 2016 15:33:42 GMT-0400 (EDT) .setIssuedAt(Date.from(Instant.ofEpochSecond(1466796822L))) // Sat Jun 24 2116 15:33:42 GMT-0400 (EDT) .setExpiration(Date.from(Instant.ofEpochSecond(4622470422L))) .signWith( SignatureAlgorithm.HS256, TextCodec.BASE64.decode("Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E=") ) .compact();

Ini sangat serupa dengan kod yang terdapat dalam kaedah StaticJWTController.fixedBuilder projek kod.

Pada ketika ini, perlu dibincangkan mengenai beberapa corak anti yang berkaitan dengan JWT dan menandatangani. Sekiranya anda pernah melihat contoh JWT sebelum ini, kemungkinan anda menghadapi salah satu senario anti-corak penandatanganan ini:

  1. .signWith( SignatureAlgorithm.HS256, "secret".getBytes("UTF-8") )
  2. .signWith( SignatureAlgorithm.HS256, "Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E=".getBytes("UTF-8") )
  3. .signWith( SignatureAlgorithm.HS512, TextCodec.BASE64.decode("Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E=") )

Mana-mana algoritma tandatangan jenis HS mengambil susunan bait. Sangat mudah bagi manusia untuk membaca untuk mengambil rentetan dan mengubahnya menjadi susunan bait.

Anti-corak 1 di atas menunjukkan ini. Ini bermasalah kerana rahsia itu dilemahkan dengan begitu pendek dan bukan susunan bait dalam bentuk asalnya. Oleh itu, untuk memastikannya dapat dibaca, kita dapat mengekod base64 array byte.

Walau bagaimanapun, anti-corak 2 di atas mengambil rentetan yang dikodkan base64 dan menukarnya terus ke susunan bait. Apa yang harus dilakukan ialah menyahkod rentetan base64 kembali ke array bait asal.

Nombor 3 di atas menunjukkan ini. Jadi, mengapa ini juga anti-corak? Ini adalah alasan yang halus dalam kes ini. Perhatikan bahawa algoritma tandatangan adalah HS512. Susunan bait bukanlah panjang maksimum yang dapat disokong oleh HS512 , menjadikannya rahsia yang lebih lemah daripada yang mungkin untuk algoritma itu.

The example code includes a class called SecretService that ensures secrets of the proper strength are used for the given algorithm. At application startup time, a new set of secrets is created for each of the HS algorithms. There are endpoints to refresh the secrets as well as to explicitly set the secrets.

If you have the project running as described above, execute the following so that the JWT examples below match the responses from your project.

http POST localhost:8080/set-secrets \ HS256="Yn2kjibddFAWtnPJ2AFlL8WXmohJMCvigQggaEypa5E=" \ HS384="VW96zL+tYlrJLNCQ0j6QPTp+d1q75n/Wa8LVvpWyG8pPZOP6AA5X7XOIlI90sDwx" \ HS512="cd+Pr1js+w2qfT2BoCD+tPcYp9LbjpmhSMEJqUob1mcxZ7+Wmik4AYdjX+DlDjmE4yporzQ9tm7v3z/j+QbdYg=="

Now, you can hit the /static-builder endpoint:

http //localhost:8080/static-builder

This produces a JWT that looks like this:

eyJhbGciOiJIUzI1NiJ9. eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIiwibmFtZSI6Ik1pY2FoIFNpbHZlcm1hbiIsInNjb3BlIjoiYWRtaW5zIiwiaWF0IjoxNDY2Nzk2ODIyLCJleHAiOjQ2MjI0NzA0MjJ9. kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ

Now, hit:

http //localhost:8080/parser?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIiwibmFtZSI6Ik1pY2FoIFNpbHZlcm1hbiIsInNjb3BlIjoiYWRtaW5zIiwiaWF0IjoxNDY2Nzk2ODIyLCJleHAiOjQ2MjI0NzA0MjJ9.kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ

The response has all the claims that we included when we created the JWT.

HTTP/1.1 200 OK Content-Type: application/json;charset=UTF-8 ... { "jws": { "body": { "exp": 4622470422, "iat": 1466796822, "iss": "Stormpath", "name": "Micah Silverman", "scope": "admins", "sub": "msilverman" }, "header": { "alg": "HS256" }, "signature": "kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ" }, "status": "SUCCESS" }

This is the parsing operation, which we'll get into in the next section.

Now, let's hit an endpoint that takes claims as parameters and will build a custom JWT for us.

http -v POST localhost:8080/dynamic-builder-general iss=Stormpath sub=msilverman hasMotorcycle:=true

Note: There's a subtle difference between the hasMotorcycle claim and the other claims. httpie assumes that JSON parameters are strings by default. To submit raw JSON using using httpie, you use the := form rather than =. Without that, it would submit “hasMotorcycle”: “true”, which is not what we want.

Here's the output:

POST /dynamic-builder-general HTTP/1.1 Accept: application/json ... { "hasMotorcycle": true, "iss": "Stormpath", "sub": "msilverman" } HTTP/1.1 200 OK Content-Type: application/json;charset=UTF-8 ... { "jwt": "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIiwiaGFzTW90b3JjeWNsZSI6dHJ1ZX0.OnyDs-zoL3-rw1GaSl_KzZzHK9GoiNocu-YwZ_nQNZU", "status": "SUCCESS" } 

Let's take a look at the code that backs this endpoint:

@RequestMapping(value = "/dynamic-builder-general", method = POST) public JwtResponse dynamicBuilderGeneric(@RequestBody Map claims) throws UnsupportedEncodingException { String jws = Jwts.builder() .setClaims(claims) .signWith( SignatureAlgorithm.HS256, secretService.getHS256SecretBytes() ) .compact(); return new JwtResponse(jws); }

Line 2 ensures that the incoming JSON is automatically converted to a Java Map, which is super handy for JJWT as the method on line 5 simply takes that Map and sets all the claims at once.

As terse as this code is, we need something more specific to ensure that the claims that are passed are valid. Using the .setClaims(Map claims) method is handy when you already know that the claims represented in the map are valid. This is where the type-safety of Java comes into the JJWT library.

For each of the Registered Claims defined in the JWT specification, there's a corresponding Java method in the JJWT that takes the spec-correct type.

Let's hit another endpoint in our example and see what happens:

http -v POST localhost:8080/dynamic-builder-specific iss=Stormpath sub:=5 hasMotorcycle:=true

Note that we've passed in an integer, 5, for the “sub” claim. Here's the output:

POST /dynamic-builder-specific HTTP/1.1 Accept: application/json ... { "hasMotorcycle": true, "iss": "Stormpath", "sub": 5 } HTTP/1.1 400 Bad Request Connection: close Content-Type: application/json;charset=UTF-8 ... { "exceptionType": "java.lang.ClassCastException", "message": "java.lang.Integer cannot be cast to java.lang.String", "status": "ERROR" }

Now, we're getting an error response because the code is enforcing the type of the Registered Claims. In this case, sub must be a string. Here's the code that backs this endpoint:

@RequestMapping(value = "/dynamic-builder-specific", method = POST) public JwtResponse dynamicBuilderSpecific(@RequestBody Map claims) throws UnsupportedEncodingException { JwtBuilder builder = Jwts.builder(); claims.forEach((key, value) -> { switch (key) { case "iss": builder.setIssuer((String) value); break; case "sub": builder.setSubject((String) value); break; case "aud": builder.setAudience((String) value); break; case "exp": builder.setExpiration(Date.from( Instant.ofEpochSecond(Long.parseLong(value.toString())) )); break; case "nbf": builder.setNotBefore(Date.from( Instant.ofEpochSecond(Long.parseLong(value.toString())) )); break; case "iat": builder.setIssuedAt(Date.from( Instant.ofEpochSecond(Long.parseLong(value.toString())) )); break; case "jti": builder.setId((String) value); break; default: builder.claim(key, value); } }); builder.signWith(SignatureAlgorithm.HS256, secretService.getHS256SecretBytes()); return new JwtResponse(builder.compact()); }

Just like before, the method accepts a Map of claims as its parameter. However, this time, we are calling the specific method for each of the Registered Claims which enforces type.

One refinement to this is to make the error message more specific. Right now, we only know that one of our claims is not the correct type. We don't know which claim was in error or what it should be. Here's a method that will give us a more specific error message. It also deals with a bug in the current code.

private void ensureType(String registeredClaim, Object value, Class expectedType) { boolean isCorrectType = expectedType.isInstance(value) || expectedType == Long.class && value instanceof Integer; if (!isCorrectType) { String msg = "Expected type: " + expectedType.getCanonicalName() + " for registered claim: '" + registeredClaim + "', but got value: " + value + " of type: " + value.getClass().getCanonicalName(); throw new JwtException(msg); } }

Line 3 checks that the passed in value is of the expected type. If not, a JwtException is thrown with the specific error. Let's take a look at this in action by making the same call we did earlier:

http -v POST localhost:8080/dynamic-builder-specific iss=Stormpath sub:=5 hasMotorcycle:=true
POST /dynamic-builder-specific HTTP/1.1 Accept: application/json ... User-Agent: HTTPie/0.9.3 { "hasMotorcycle": true, "iss": "Stormpath", "sub": 5 } HTTP/1.1 400 Bad Request Connection: close Content-Type: application/json;charset=UTF-8 ... { "exceptionType": "io.jsonwebtoken.JwtException", "message": "Expected type: java.lang.String for registered claim: 'sub', but got value: 5 of type: java.lang.Integer", "status": "ERROR" }

Now, we have a very specific error message telling us that the sub claim is the one in error.

Let's circle back to that bug in our code. The issue has nothing to do with the JJWT library. The issue is that the JSON to Java Object mapper built into Spring Boot is too smart for our own good.

If there's a method that accepts a Java Object, the JSON mapper will automatically convert a passed in number that is less than or equal to 2,147,483,647 into a Java Integer. Likewise, it will automatically convert a passed in number that is greater than 2,147,483,647 into a Java Long. For the iat, nbf, and exp claims of a JWT, we want our ensureType test to pass whether the mapped Object is an Integer or a Long. That's why we have the additional clause in determining if the passed in value is the correct type:

 boolean isCorrectType = expectedType.isInstance(value) || expectedType == Long.class && value instanceof Integer;

If we're expecting a Long, but the value is an instance of Integer, we still say it's the correct type. With an understanding of what's happening with this validation, we can now integrate it into our dynamicBuilderSpecific method:

@RequestMapping(value = "/dynamic-builder-specific", method = POST) public JwtResponse dynamicBuilderSpecific(@RequestBody Map claims) throws UnsupportedEncodingException { JwtBuilder builder = Jwts.builder(); claims.forEach((key, value) -> { switch (key) { case "iss": ensureType(key, value, String.class); builder.setIssuer((String) value); break; case "sub": ensureType(key, value, String.class); builder.setSubject((String) value); break; case "aud": ensureType(key, value, String.class); builder.setAudience((String) value); break; case "exp": ensureType(key, value, Long.class); builder.setExpiration(Date.from( Instant.ofEpochSecond(Long.parseLong(value.toString())) )); break; case "nbf": ensureType(key, value, Long.class); builder.setNotBefore(Date.from( Instant.ofEpochSecond(Long.parseLong(value.toString())) )); break; case "iat": ensureType(key, value, Long.class); builder.setIssuedAt(Date.from( Instant.ofEpochSecond(Long.parseLong(value.toString())) )); break; case "jti": ensureType(key, value, String.class); builder.setId((String) value); break; default: builder.claim(key, value); } }); builder.signWith(SignatureAlgorithm.HS256, secretService.getHS256SecretBytes()); return new JwtResponse(builder.compact()); }

Note: In all the example code in this section, JWTs are signed with the HMAC using SHA-256 algorithm. This is to keep the examples simple. The JJWT library supports 12 different signature algorithms that you can take advantage of in your own code.

5. Parsing JWTs With JJWT

We saw earlier that our code example has an endpoint for parsing a JWT. Hitting this endpoint:

http //localhost:8080/parser?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIiwibmFtZSI6Ik1pY2FoIFNpbHZlcm1hbiIsInNjb3BlIjoiYWRtaW5zIiwiaWF0IjoxNDY2Nzk2ODIyLCJleHAiOjQ2MjI0NzA0MjJ9.kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ

produces this response:

HTTP/1.1 200 OK Content-Type: application/json;charset=UTF-8 ... { "claims": { "body": { "exp": 4622470422, "iat": 1466796822, "iss": "Stormpath", "name": "Micah Silverman", "scope": "admins", "sub": "msilverman" }, "header": { "alg": "HS256" }, "signature": "kP0i_RvTAmI8mgpIkDFhRX3XthSdP-eqqFKGcU92ZIQ" }, "status": "SUCCESS" }

The parser method of the StaticJWTController class looks like this:

@RequestMapping(value = "/parser", method = GET) public JwtResponse parser(@RequestParam String jwt) throws UnsupportedEncodingException { Jws jws = Jwts.parser() .setSigningKeyResolver(secretService.getSigningKeyResolver()) .parseClaimsJws(jwt); return new JwtResponse(jws); }

Line 4 indicates that we expect the incoming string to be a signed JWT (a JWS). And, we are using the same secret that was used to sign the JWT in parsing it. Line 5 parses the claims from the JWT. Internally, it is verifying the signature and it will throw an exception if the signature is invalid.

Notice that in this case we are passing in a SigningKeyResolver rather than a key itself. This is one of the most powerful aspects of JJWT. The header of JWT indicates the algorithm used to sign it. However, we need to verify the JWT before we trust it. It would seem to be a catch 22. Let's look at the SecretService.getSigningKeyResolver method:

private SigningKeyResolver signingKeyResolver = new SigningKeyResolverAdapter() { @Override public byte[] resolveSigningKeyBytes(JwsHeader header, Claims claims) { return TextCodec.BASE64.decode(secrets.get(header.getAlgorithm())); } };

Using the access to the JwsHeader, I can inspect the algorithm and return the proper byte array for the secret that was used to sign the JWT. Now, JJWT will verify that the JWT has not been tampered with using this byte array as the key.

If I remove the last character of the passed in JWT (which is part of the signature), this is the response:

HTTP/1.1 400 Bad Request Connection: close Content-Type: application/json;charset=UTF-8 Date: Mon, 27 Jun 2016 13:19:08 GMT Server: Apache-Coyote/1.1 Transfer-Encoding: chunked { "exceptionType": "io.jsonwebtoken.SignatureException", "message": "JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.", "status": "ERROR" }

6. JWTs in Practice: Spring Security CSRF Tokens

While the focus of this post is not Spring Security, we are going to delve into it a bit here to showcase some real-world usage of the JJWT library.

Cross Site Request Forgery is a security vulnerability whereby a malicious website tricks you into submitting requests to a website that you have established trust with. One of the common remedies for this is to implement a synchronizer token pattern. This approach inserts a token into the web form and the application server checks the incoming token against its repository to confirm that it is correct. If the token is missing or invalid, the server will respond with an error.

Spring Security has the synchronizer token pattern built in. Even better, if you are using the Spring Boot and Thymeleaf templates, the synchronizer token is automatically inserted for you.

By default, the token that Spring Security uses is a “dumb” token. It's just a series of letters and numbers. This approach is just fine and it works. In this section, we enhance the basic functionality by using JWTs as the token. In addition to verifying that the submitted token is the one expected, we validate the JWT to further prove that the token has not been tampered with and to ensure that it is not expired.

To get started, we are going to configure Spring Security using Java configuration. By default, all paths require authentication and all POST endpoints require CSRF tokens. We are going to relax that a bit so that what we've built so far still works.

@Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { private String[] ignoreCsrfAntMatchers = { "/dynamic-builder-compress", "/dynamic-builder-general", "/dynamic-builder-specific", "/set-secrets" }; @Override protected void configure(HttpSecurity http) throws Exception { http .csrf() .ignoringAntMatchers(ignoreCsrfAntMatchers) .and().authorizeRequests() .antMatchers("/**") .permitAll(); } }

We are doing two things here. First, we are saying the CSRF tokens are not required when posting to our REST API endpoints (line 15). Second, we are saying that unauthenticated access should be allowed for all paths (lines 17 – 18).

Let's confirm that Spring Security is working the way we expect. Fire up the app and hit this url in your browser:

//localhost:8080/jwt-csrf-form

Here's the Thymeleaf template for this view:

This is a very basic form that will POST to the same endpoint when submitted. Notice that there is no explicit reference to CSRF tokens in the form. If you view the source, you will see something like:

This is all the confirmation you need to know that Spring Security is functioning and that the Thymeleaf templates are automatically inserting the CSRF token.

To make the value a JWT, we will enable a custom CsrfTokenRepository. Here's how our Spring Security configuration changes:

@Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired CsrfTokenRepository jwtCsrfTokenRepository; @Override protected void configure(HttpSecurity http) throws Exception { http .csrf() .csrfTokenRepository(jwtCsrfTokenRepository) .ignoringAntMatchers(ignoreCsrfAntMatchers) .and().authorizeRequests() .antMatchers("/**") .permitAll(); } }

To connect this, we need a configuration that exposes a bean that returns the custom token repository. Here's the configuration:

@Configuration public class CSRFConfig { @Autowired SecretService secretService; @Bean @ConditionalOnMissingBean public CsrfTokenRepository jwtCsrfTokenRepository() { return new JWTCsrfTokenRepository(secretService.getHS256SecretBytes()); } }

And, here's our custom repository (the important bits):

public class JWTCsrfTokenRepository implements CsrfTokenRepository { private static final Logger log = LoggerFactory.getLogger(JWTCsrfTokenRepository.class); private byte[] secret; public JWTCsrfTokenRepository(byte[] secret) { this.secret = secret; } @Override public CsrfToken generateToken(HttpServletRequest request) { String id = UUID.randomUUID().toString().replace("-", ""); Date now = new Date(); Date exp = new Date(System.currentTimeMillis() + (1000*30)); // 30 seconds String token; try { token = Jwts.builder() .setId(id) .setIssuedAt(now) .setNotBefore(now) .setExpiration(exp) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } catch (UnsupportedEncodingException e) { log.error("Unable to create CSRf JWT: {}", e.getMessage(), e); token = id; } return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token); } @Override public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) { ... } @Override public CsrfToken loadToken(HttpServletRequest request) { ... } }

The generateToken method creates a JWT that expires 30 seconds after it's created. With this plumbing in place, we can fire up the application again and look at the source of /jwt-csrf-form.

Now, the hidden field looks like this:

Huzzah! Now our CSRF token is a JWT. That wasn't too hard.

However, this is only half the puzzle. By default, Spring Security simply saves the CSRF token and confirms that the token submitted in a web form matches the one that's saved. We want to extend the functionality to validate the JWT and make sure it hasn't expired. To do that, we'll add in a filter. Here's what our Spring Security configuration looks like now:

@Configuration public class WebSecurityConfig extends WebSecurityConfigurerAdapter { ... @Override protected void configure(HttpSecurity http) throws Exception { http .addFilterAfter(new JwtCsrfValidatorFilter(), CsrfFilter.class) .csrf() .csrfTokenRepository(jwtCsrfTokenRepository) .ignoringAntMatchers(ignoreCsrfAntMatchers) .and().authorizeRequests() .antMatchers("/**") .permitAll(); } ... }

On line 9, we've added in a filter and we are placing it in the filter chain after the default CsrfFilter. So, by the time our filter is hit, the JWT token (as a whole) will have already been confirmed to be the correct value saved by Spring Security.

Here's the JwtCsrfValidatorFilter (it's private as it's an inner class of our Spring Security configuration):

private class JwtCsrfValidatorFilter extends OncePerRequestFilter { @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // NOTE: A real implementation should have a nonce cache so the token cannot be reused CsrfToken token = (CsrfToken) request.getAttribute("_csrf"); if ( // only care if it's a POST "POST".equals(request.getMethod()) && // ignore if the request path is in our list Arrays.binarySearch(ignoreCsrfAntMatchers, request.getServletPath()) < 0 && // make sure we have a token token != null ) { // CsrfFilter already made sure the token matched. // Here, we'll make sure it's not expired try { Jwts.parser() .setSigningKey(secret.getBytes("UTF-8")) .parseClaimsJws(token.getToken()); } catch (JwtException e) { // most likely an ExpiredJwtException, but this will handle any request.setAttribute("exception", e); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); RequestDispatcher dispatcher = request.getRequestDispatcher("expired-jwt"); dispatcher.forward(request, response); } } filterChain.doFilter(request, response); } }

Take a look at line 23 on. We are parsing the JWT as before. In this case, if an Exception is thrown, the request is forwarded to the expired-jwt template. If the JWT validates, then processing continues as normal.

This closes the loop on overriding the default Spring Security CSRF token behavior with a JWT token repository and validator.

If you fire up the app, browse to /jwt-csrf-form, wait a little more than 30 seconds and click the button, you will see something like this:

7. JJWT Extended Features

We'll close out our JJWT journey with a word on some of the features that extend beyond the specification.

7.1. Enforce Claims

As part of the parsing process, JJWT allows you to specify required claims and values those claims should have. This is very handy if there is certain information in your JWTs that must be present in order for you to consider them valid. It avoids a lot of branching logic to manually validate claims. Here's the method that serves the /parser-enforce endpoint of our sample project.

@RequestMapping(value = "/parser-enforce", method = GET) public JwtResponse parserEnforce(@RequestParam String jwt) throws UnsupportedEncodingException { Jws jws = Jwts.parser() .requireIssuer("Stormpath") .require("hasMotorcycle", true) .setSigningKeyResolver(secretService.getSigningKeyResolver()) .parseClaimsJws(jwt); return new JwtResponse(jws); }

Lines 5 and 6 show you the syntax for registered claims as well as custom claims. In this example, the JWT will be considered invalid if the iss claim is not present or does not have the value: Stormpath. It will also be invalid if the custom hasMotorcycle claim is not present or does not have the value: true.

Let's first create a JWT that follows the happy path:

http -v POST localhost:8080/dynamic-builder-specific \ iss=Stormpath hasMotorcycle:=true sub=msilverman
POST /dynamic-builder-specific HTTP/1.1 Accept: application/json ... { "hasMotorcycle": true, "iss": "Stormpath", "sub": "msilverman" } HTTP/1.1 200 OK Cache-Control: no-cache, no-store, max-age=0, must-revalidate Content-Type: application/json;charset=UTF-8 ... { "jwt": "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJoYXNNb3RvcmN5Y2xlIjp0cnVlLCJzdWIiOiJtc2lsdmVybWFuIn0.qrH-U6TLSVlHkZdYuqPRDtgKNr1RilFYQJtJbcgwhR0", "status": "SUCCESS" }

Now, let's validate that JWT:

http -v localhost:8080/parser-enforce?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJoYXNNb3RvcmN5Y2xlIjp0cnVlLCJzdWIiOiJtc2lsdmVybWFuIn0.qrH-U6TLSVlHkZdYuqPRDtgKNr1RilFYQJtJbcgwhR0
GET /parser-enforce?jwt=http -v localhost:8080/parser-enforce?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJoYXNNb3RvcmN5Y2xlIjp0cnVlLCJzdWIiOiJtc2lsdmVybWFuIn0.qrH-U6TLSVlHkZdYuqPRDtgKNr1RilFYQJtJbcgwhR0 HTTP/1.1 Accept: */* ... HTTP/1.1 200 OK Cache-Control: no-cache, no-store, max-age=0, must-revalidate Content-Type: application/json;charset=UTF-8 ... { "jws": { "body": { "hasMotorcycle": true, "iss": "Stormpath", "sub": "msilverman" }, "header": { "alg": "HS256" }, "signature": "qrH-U6TLSVlHkZdYuqPRDtgKNr1RilFYQJtJbcgwhR0" }, "status": "SUCCESS" }

So far, so good. Now, this time, let's leave the hasMotorcycle out:

http -v POST localhost:8080/dynamic-builder-specific iss=Stormpath sub=msilverman

This time, if we try to validate the JWT:

http -v localhost:8080/parser-enforce?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIn0.YMONlFM1tNgttUYukDRsi9gKIocxdGAOLaJBymaQAWc

we get:

GET /parser-enforce?jwt=http -v localhost:8080/parser-enforce?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJzdWIiOiJtc2lsdmVybWFuIn0.YMONlFM1tNgttUYukDRsi9gKIocxdGAOLaJBymaQAWc HTTP/1.1 Accept: */* ... HTTP/1.1 400 Bad Request Cache-Control: no-cache, no-store, max-age=0, must-revalidate Connection: close Content-Type: application/json;charset=UTF-8 ... { "exceptionType": "io.jsonwebtoken.MissingClaimException", "message": "Expected hasMotorcycle claim to be: true, but was not present in the JWT claims.", "status": "ERROR" }

This indicates that our hasMotorcycle claim was expected, but was missing.

Let's do one more example:

http -v POST localhost:8080/dynamic-builder-specific iss=Stormpath hasMotorcycle:=false sub=msilverman

This time, the required claim is present, but it has the wrong value. Let's see the output of:

http -v localhost:8080/parser-enforce?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJoYXNNb3RvcmN5Y2xlIjpmYWxzZSwic3ViIjoibXNpbHZlcm1hbiJ9.8LBq2f0eINB34AzhVEgsln_KDo-IyeM8kc-dTzSCr0c
GET /parser-enforce?jwt=http -v localhost:8080/parser-enforce?jwt=eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJoYXNNb3RvcmN5Y2xlIjpmYWxzZSwic3ViIjoibXNpbHZlcm1hbiJ9.8LBq2f0eINB34AzhVEgsln_KDo-IyeM8kc-dTzSCr0c HTTP/1.1 Accept: */* ... HTTP/1.1 400 Bad Request Cache-Control: no-cache, no-store, max-age=0, must-revalidate Connection: close Content-Type: application/json;charset=UTF-8 ... { "exceptionType": "io.jsonwebtoken.IncorrectClaimException", "message": "Expected hasMotorcycle claim to be: true, but was: false.", "status": "ERROR" }

This indicates that our hasMotorcycle claim was present, but had a value that was not expected.

MissingClaimException and IncorrectClaimException are your friends when enforcing claims in your JWTs and a feature that only the JJWT library has.

7.2. JWT Compression

If you have a lot of claims on a JWT, it can get big – so big, that it might not fit in a GET url in some browsers.

Let's a make a big JWT:

http -v POST localhost:8080/dynamic-builder-specific \ iss=Stormpath hasMotorcycle:=true sub=msilverman the=quick brown=fox jumped=over lazy=dog \ somewhere=over rainbow=way up=high and=the dreams=you dreamed=of

Here's the JWT that produces:

eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdG9ybXBhdGgiLCJoYXNNb3RvcmN5Y2xlIjp0cnVlLCJzdWIiOiJtc2lsdmVybWFuIiwidGhlIjoicXVpY2siLCJicm93biI6ImZveCIsImp1bXBlZCI6Im92ZXIiLCJsYXp5IjoiZG9nIiwic29tZXdoZXJlIjoib3ZlciIsInJhaW5ib3ciOiJ3YXkiLCJ1cCI6ImhpZ2giLCJhbmQiOiJ0aGUiLCJkcmVhbXMiOiJ5b3UiLCJkcmVhbWVkIjoib2YifQ.AHNJxSTiDw_bWNXcuh-LtPLvSjJqwDvOOUcmkk7CyZA

That sucker's big! Now, let's hit a slightly different endpoint with the same claims:

http -v POST localhost:8080/dynamic-builder-compress \ iss=Stormpath hasMotorcycle:=true sub=msilverman the=quick brown=fox jumped=over lazy=dog \ somewhere=over rainbow=way up=high and=the dreams=you dreamed=of

This time, we get:

eyJhbGciOiJIUzI1NiIsImNhbGciOiJERUYifQ.eNpEzkESwjAIBdC7sO4JegdXnoC2tIk2oZLEGB3v7s84jjse_AFe5FOikc5ZLRycHQ3kOJ0Untu8C43ZigyUyoRYSH6_iwWOyGWHKd2Kn6_QZFojvOoDupRwyAIq4vDOzwYtugFJg1QnJv-5sY-TVjQqN7gcKJ3f-j8c-6J-baDFhEN_uGn58XtnpfcHAAD__w.3_wc-2skFBbInk0YAQ96yGWwr8r1xVdbHn-uGPTFuFE

62 characters shorter! Here's the code for the method used to generate the JWT:

@RequestMapping(value = "/dynamic-builder-compress", method = POST) public JwtResponse dynamicBuildercompress(@RequestBody Map claims) throws UnsupportedEncodingException { String jws = Jwts.builder() .setClaims(claims) .compressWith(CompressionCodecs.DEFLATE) .signWith( SignatureAlgorithm.HS256, secretService.getHS256SecretBytes() ) .compact(); return new JwtResponse(jws); }

Notice on line 6 we are specifying a compression algorithm to use. That's all there is to it.

What about parsing compressed JWTs? The JJWT library automatically detects the compression and uses the same algorithm to decompress:

GET /parser?jwt=eyJhbGciOiJIUzI1NiIsImNhbGciOiJERUYifQ.eNpEzkESwjAIBdC7sO4JegdXnoC2tIk2oZLEGB3v7s84jjse_AFe5FOikc5ZLRycHQ3kOJ0Untu8C43ZigyUyoRYSH6_iwWOyGWHKd2Kn6_QZFojvOoDupRwyAIq4vDOzwYtugFJg1QnJv-5sY-TVjQqN7gcKJ3f-j8c-6J-baDFhEN_uGn58XtnpfcHAAD__w.3_wc-2skFBbInk0YAQ96yGWwr8r1xVdbHn-uGPTFuFE HTTP/1.1 Accept: */* ... HTTP/1.1 200 OK Cache-Control: no-cache, no-store, max-age=0, must-revalidate Content-Type: application/json;charset=UTF-8 ... { "claims": { "body": { "and": "the", "brown": "fox", "dreamed": "of", "dreams": "you", "hasMotorcycle": true, "iss": "Stormpath", "jumped": "over", "lazy": "dog", "rainbow": "way", "somewhere": "over", "sub": "msilverman", "the": "quick", "up": "high" }, "header": { "alg": "HS256", "calg": "DEF" }, "signature": "3_wc-2skFBbInk0YAQ96yGWwr8r1xVdbHn-uGPTFuFE" }, "status": "SUCCESS" }

Notice the calg claim in the header. This was automatically encoded into the JWT and it provides the hint to the parser about what algorithm to use for decompression.

NOTE: The JWE specification does support compression. In an upcoming release of the JJWT library, we will support JWE and compressed JWEs. We will continue to support compression in other types of JWTs, even though it is not specified.

8. Token Tools for Java Devs

While the core focus of this article was not Spring Boot or Spring Security, using those two technologies made it easy to demonstrate all the features discussed in this article. You should be able to build in fire up the server and start playing with the various endpoints we've discussed. Just hit:

http //localhost:8080

Stormpath is also excited to bring a number of open source developer tools to the Java community. These include:

8.1. JJWT (What We've Been Talking About)

JJWT is an easy to use tool for developers to create and verify JWTs in Java. Like many libraries Stormpath supports, JJWT is completely free and open source (Apache License, Version 2.0), so everyone can see what it does and how it does it. Do not hesitate to report any issues, suggest improvements, and even submit some code!

8.2. jsonwebtoken.io and java.jsonwebtoken.io

jsonwebtoken.io is a developer tool we created to make it easy to decode JWTs. Simply paste an existing JWT into the appropriate field to decode its header, payload, and signature. jsonwebtoken.io is powered by nJWT, the cleanest free and open source (Apache License, Version 2.0) JWT library for Node.js developers. You can also see code generated for a variety of languages at this website. The website itself is open-source and can be found here.

java.jsonwebtoken.io is specifically for the JJWT library. You can alter the headers and payload in the upper right box, see the JWT generated by JJWT in the upper left box, and see a sample of the builder and parser Java code in the lower boxes. The website itself is open source and can be found here.

8.3. JWT Inspector

The new kid on the block, JWT Inspector is an open source Chrome extension that allows developers to inspect and debug JWTs directly in-browser. The JWT Inspector will discover JWTs on your site (in cookies, local/session storage, and headers) and make them easily accessible through your navigation bar and DevTools panel.

9. JWT This Down!

JWTs add some intelligence to ordinary tokens. The ability to cryptographically sign and verify, build in expiration times and encode other information into JWTs sets the stage for truly stateless session management. This has a big impact on the ability to scale applications.

At Stormpath, we use JWTs for OAuth2 tokens, CSRF tokens and assertions between microservices, among other usages.

Sebaik sahaja anda mula menggunakan JWT, anda mungkin tidak akan pernah kembali ke token bodoh masa lalu. Ada sebarang pertanyaan? Tekan saya di @afitnerd di twitter.