Обеспечение безопасности веб сервисов — одна из важных частей процесса разработки. Если если в инфраструктуре несколько сервисов, то каждый из них должен быть должным образом защищен. Если реализовывать проверки политик безопасности в каждом сервисе, то затраты на разработку и поддержку таких сервисов существенно возрастают. При этом не избежать дублирования кода и ошибок разработки. Поэтому, управление защитой сервисов должно быть централизованным. Далее мы рассмотрим, как организовать централизованную защиту приложений на примере API-шлюза с открытым исходным кодом OpenIG, а так же добавим проверку авторизации доступа с JWT токеном
Исходный код для статьи https://github.com/maximthomas/openig-protect-ws/
Демонстрационный сервис
Пусть у нас есть сервис, разработанный на Spring Boot, с двумя endpoint / — публичной и /secure — приватной, доступ к которой могут иметь только аутентифицированные пользователи.
Пример сервиса:
package org.openidentityplatform.sampleservice; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; import java.util.Collections; import java.util.Map; @SpringBootApplication public class SampleServiceApplication { public static void main(String[] args) { SpringApplication.run(SampleServiceApplication.class, args); } @RestController public class IndexController { @RequestMapping("/") public Map<String, String> index() { return Collections.singletonMap("hello", "world"); } @RequestMapping("/secure") public Map<String, String> secure(HttpServletRequest request) { return Collections.singletonMap("hello", request.getHeader("X-Auth-Username")); } } }
Запуск демонстрационного сервиса
Создайте docker-compose.yaml файл и добавьте в него демонстрационный сервис:
services: sample-service: image: maximthomas/sample-service restart: always
Демонстрационный сервис будет работать без доступа из внешней сети. Далее мы добавим Docker контейнер со шлюзом OpenIG, который будет валидировать запросы и проксировать их до демонстрационного сервиса
Настройка OpenIG
Создайте директорию с конфигурацией OpenIG - openig-config в этой папке создайте еще одну директорию config . В папке openig-config/config создайте 2 файла конфигурации:
admin.json
{ "prefix" : "openig", "mode": "PRODUCTION" }
и config.json
{ "heap": [ ], "handler": { "type": "Chain", "config": { "filters": [ ], "handler": { "type": "Router", "name": "_router", "capture": "all" } } } }
Добавьте сервис OpenIG в файл docker-compose.yaml Смонтируйте папку конфигурации openig-config к Docker контейнеру OpenIG. Значение системной опции -Dopenig.base должно указывать на смонтированную в контейнере директорию.
services: sample-service: build: context: ./sample-service restart: always #OpenIG service openig: image: openidentityplatform/openig:latest restart: always volumes: - ./openig-config:/usr/local/openig-config environment: #OpenIG options CATALINA_OPTS: -Dopenig.base=/usr/local/openig-config ports: - "8080:8080"
Проксирование запросов к сервису
Настроим проксирование запросов через OpenIG к демонстрационному сервису. Добавьте системную настройку -Dendpoint.api. Она будет указывать на URL демонстрационного сервиса и будет использоваться в настройках маршрутов OpenIG. Вы, конечно, можете прописать конечные точки непосредственно в маршруте, но, использование системных опций является рекомендуемым подходом.
docker-compose.yaml:
... openig: image: openidentityplatform/openig:latest restart: always volumes: - ./openig-config:/usr/local/openig-config environment: #OpenIG options CATALINA_OPTS: -Dopenig.base=/usr/local/openig-config -Dendpoint.api=http://sample-service:8080/ ports: - "8080:808
Добавьте маршрут, который будет проксировать запросы на сервис. Создайте папку routes с маршрутами в директории openig-config/config/. И добавьте в нее файл конфигурации маршрута
10-api.json
{ "name": "${matches(request.uri.path, '^/')}", "condition": "${matches(request.uri.path, '^/')}", "monitor": true, "timer": true, "handler": { "type": "Chain", "config": { "filters": [ ], "handler": "EndpointHandler" } }, "heap": [ { "name": "EndpointHandler", "type": "DispatchHandler", "config": { "bindings": [ { "handler": "ClientHandler", "capture": "all", "baseURI": "${system['endpoint.api']}" } ] } } ] }
Такой маршрут проксирует все запросы на демонстрационный сервис из возвращает ответы без каких либо проверок.
Добавьте маршрут по умолчанию, который будет возвращать 404 статус на все остальные запросы
99-default.json:
{ "name": "99-default", "handler": { "type": "StaticResponseHandler", "config": { "status": 404, "reason": "Not Found", "headers": { "Content-Type": [ "application/json" ] }, "entity": "{ \"error\": \"Not Found\"}" } }, "audit": "/404" }
Запустим демо сервис и OpenIG в Docker контейнерах:
docker-compose up
После запуска проверим работоспособность
curl -v -X GET http://localhost:8080/ Note: Unnecessary use of -X or --request, GET is already inferred. * Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 8080 (#0) > GET / HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.58.0 > Accept: */* > < HTTP/1.1 200 OK < Server: Apache-Coyote/1.1 < Date: Wed, 24 Apr 2019 15:06:17 GMT < Content-Type: application/json;charset=UTF-8 < Transfer-Encoding: chunked < * Connection #0 to host localhost left intact {"hello":"world"} curl -v -X GET http://localhost:8080/secure Note: Unnecessary use of -X or --request, GET is already inferred. * Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 8080 (#0) > GET /secure HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.58.0 > Accept: */* > < HTTP/1.1 200 OK < Server: Apache-Coyote/1.1 < Date: Wed, 24 Apr 2019 15:04:49 GMT < Content-Type: application/json;charset=UTF-8 < Transfer-Encoding: chunked < * Connection #0 to host localhost left intact {"name":null}
После настройки проксирования запросов, давайте обеспечим безопасность демонстрационного сервиса
Защита сервиса
Для примера возьмем рекомендации OWASP по защите REST сервисов.
Ограничение методов HTTP
Добавим возможность пропускать к сервису только GET и POST запросы. Добавим в маршрут 10-api.json фильтр SwitchFilter
{ "name": "${matches(request.uri.path, '^/')}", "condition": "${matches(request.uri.path, '^/')}", "monitor": true, "timer": true, "handler": { "type": "Chain", "config": { "filters": [ { "type": "SwitchFilter", "config": { "onRequest": [ { "condition": "${request.method != 'POST' and request.method != 'GET'}", "handler": { "type": "StaticResponseHandler", "config": { "status": 405, "reason": "Method not allowed", "headers": { "Content-Type": [ "application/json" ] }, "entity": "{ \"error\": \"Method not allowed\"}" } } } ] } } ], "handler": "EndpointHandler" } }, "heap": [ { "name": "EndpointHandler", "type": "DispatchHandler", "config": { "bindings": [ { "handler": "ClientHandler", "capture": "all", "baseURI": "${system['endpoint.api']}" } ] } } ] }
Проверим, что если запрос не GET и не POST шлюз ��ернет статус 405:
$ curl -v -X PUT http://localhost:8080/ * Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 8080 (#0) > PUT / HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.58.0 > Accept: */* > < HTTP/1.1 405 Method Not Allowed < Server: Apache-Coyote/1.1 < Content-Type: application/json < Content-Length: 32 < Date: Wed, 24 Apr 2019 15:13:04 GMT < * Connection #0 to host localhost left intact { "error": "Method not allowed"}
Проверка заголовка запроса Content-Type
Пусть для демонстрационного сервиса будут допустимы POST запросы только с Content-Type: application/json . Для этого добавьте в SwitchFilter проверку заголовка Content-Type
10-api.json:
... { "type": "SwitchFilter", "config": { "onRequest": [ { "condition": "${request.method != 'POST' and request.method != 'GET'}", "handler": { "type": "StaticResponseHandler", "config": { "status": 405, "reason": "Method not allowed", "headers": { "Content-Type": [ "application/json" ] }, "entity": "{ \"error\": \"Method not allowed\"}" } } }, { "condition": "${request.method == 'POST' and request.headers['Content-Type'][0].split(';')[0] != 'application/json'}", "handler": { "type": "StaticResponseHandler", "config": { "status": 415, "reason": "Unsupported Media Type", "headers": { "Content-Type": [ "application/json" ] }, "entity": "{ \"error\": \"Unsupported Media Type\"}" } } } ] } } ...
Проверим, что ограничение работает для Content-Type: application/xml
$ curl -v -X POST -H 'Content-Type: application/xml' http://localhost:8080/ * Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 8080 (#0) > POST / HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.58.0 > Accept: */* > Content-Type: application/xml > < HTTP/1.1 415 Unsupported Media Type < Server: Apache-Coyote/1.1 < Content-Type: application/json < Content-Length: 36 < Date: Wed, 24 Apr 2019 15:21:04 GMT < * Connection #0 to host localhost left intact { "error": "Unsupported Media Type"}
Проверка совпадения заголовков Accept запроса и Content-Type ответа
Значение заголовка Content-Type ответа должно совпадать со значением заголовка Accept запроса. Добавьте условие проверки в объект config фильтра SwitchFilter маршрута:
10-api.json:
... "onResponse" : [ { "condition" : "${response.headers['Content-Type'][0].split(';')[0] != request.headers['Accept'][0].split(';')[0] }", "handler": { "type": "StaticResponseHandler", "config": { "status": 406, "reason": "Not Acceptable", "headers": { "Content-Type": [ "application/json" ] }, "entity": "{ \"error\": \"Not Acceptable\"}" } } } ] ...
Проверим запрос с заголовком Accept: application/xml
curl -v -X POST -H 'Content-Type: application/json' -H 'Accept: application/xml' http://localhost:8080/ * Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 8080 (#0) > POST / HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.58.0 > Content-Type: application/json > Accept: application/xml > < HTTP/1.1 406 Not Acceptable < Server: Apache-Coyote/1.1 < Content-Type: application/json < Content-Length: 28 < Date: Wed, 24 Apr 2019 15:28:54 GMT < * Connection #0 to host localhost left intact { "error": "Not Acceptable"}
Добавление заголовков безопасности X-Frame-Options и X-Content-Type-Options
OpenIG должен возвращать клиенту заголовки X-Frame-Options: deny и X-Content-Type-Options: nosniff, чтобы предотвратить MIME sniffing, XSS и drag'n drop clickjacking атаки. Для этого добавьте HeaderFilter в цепочку фильтров после SwitchFilter:
10-api.json:
{ "type": "HeaderFilter", "comment": "Add security headers to response", "config": { "messageType": "response", "add": { "X-Frame-Options": [ "deny" ], "X-Content-Type-Options": [ "nosniff" ] } } }
Проверим заголовки ответа:
curl -v -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' http://localhost:8080/ * Trying 127.0.0.1... * TCP_NODELAY set * Connected to localhost (127.0.0.1) port 8080 (#0) > POST / HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/7.58.0 > Content-Type: application/json > Accept: application/json > < HTTP/1.1 200 OK < Server: Apache-Coyote/1.1 < Date: Wed, 24 Apr 2019 15:31:31 GMT < X-Content-Type-Options: nosniff < X-Frame-Options: deny < Content-Type: application/json;charset=UTF-8 < Transfer-Encoding: chunked < * Connection #0 to host localhost left intact {"hello":"world"}
Проверка аутентификации и авторизации
Если вам нужно защитить сервис от не аутентифицированного доступа, нет необходимости реализовывать проверку аутентификации для каждого сервиса. Вы можете проверить доступ непосредственно на OpenIG. И если запрос аутентифицирован, обогатить запрос заголовком информацией об учетной записи. Например, сервис аутентификации возвращает клиенту подписанный JSON Web Token (JWT) и шлюз использует переданный клиентом JWT для авторизации доступа к сервису. В конфигурации OpenIG лежит публичный ключ и OpenIG проверяет подпись JWT с этим ключом, для того, чтобы удостовериться в подлинности JWT.
Сгенерируйте пару ключей
Публичный
openssl genrsa -out private_key.pem 4096
И приватный
openssl rsa -pubout -in private_key.pem -out public_key.pem
Уже сгенерированные ключи лежат в GitHub репозитории https://github.com/maximthomas/openig-protect-ws/tree/master/openig-config/keys
Сгенерируйте JWT при помощи сайта https://jwt.io и сгенерированного приватного ключа private_key.pem
Пример JWT:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzYW1wbGUtc2VydmljZSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJyb2xlIjoibWFuYWdlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNzI2MjM5MDIyfQ.bhzhwj2cY1iYbpx7Mzbukfi1jOCvWP-Pdd9dm3hf7lZDDuokNVDUXU3jvHial4QN-bOTSNCUKVy907hokcVeQaFwbiYoZs485Kr230Z0y9MU6zbDe8yQp68-71TDgJGIZ78YYOKvJTrzCWgWgE_Py1DskG_ViSxfGFlETpFQa056Rk2bty-9iuc_Cx5_Wr6RCcJTG6WYRrBtdWGIFxljEjxSAcJYmGPPA8dHHORDOnmka2OAjWURnsqbz50aI_SrWpnqp4i2eXVA1b5QD5rlsgc_QAqJptghrijBlRPhasTk1N-kXE8Ozboa0FwGfIRr7gNiG-3if7INZe2R5NUCmjlAlywcSfOunWuSzY8tLGTHV2swnQPP8lBXwVJcS5nJMqBNIbcLcFWHk3ryvvtf-LYty_jAY8v1zDe9-DwFPWI0rry8fmiZj7yhAnvX5EHZHvSQtp_zyPpVWvOXFPwasI0jdKoxhWvyJpbmw-D95J5CgJAMfkrWPDQKVt3ipebwnMJStA3xAPPyl28mTBYhJrT6gzIOS3DseoVKK4adn34ZrQi2Hm-wyUtbdulopK739MKM73NYgoFXSJeVUqcy4iw3-In5XmOhdRnUL50TSiaNBbkys8iK7r00HD3kI3CH0GfaPdrcgRgaFXKmVDhX-tEaPJYcuEUTHfQAxWwMdiw
Проверка JWT в OpenIG
Добавим проверку аутентификации для конечно точки /secured демонстрационного сервиса. Для этого добавим еще один SwitchFilter , который, в свою очередь, вызовет обработчикChain если целевая конечная точка является secured . Добавьте в обработчик Chain фильтр ScriptableFilter , который будет проверять валидность JWT и обогащать запрос идентификатором учетной записи из JWT.
10-api.json:
... { "type": "SwitchFilter", "config": { "onRequest": [ { "condition": "${matches(request.uri.path, '^/secure')}", "handler": { "type": "Chain", "config": { "filters": [ { "type": "ScriptableFilter", "config": { "type": "application/x-groovy", "file": "jwt.groovy", "args": { "iss": { "sample-service": "${read('/usr/local/openig-config/keys/public_key.pem')}" } } } } ], "handler": "EndpointHandler" } } } ] } } ...
Добавьте файл jwt.groovy в папку /openig-config/scripts/groovy/ . Скрипт проверяет подпись, и, если подпись верна, проверяет срок истечения JWT. Если JWT валиден, скрипт обогащает запрос заголовком X-Auth-Username из поля name полезной нагрузки JWT. В противном случае возвращается 401 статус HTTP.
jwt.groovy:
import java.security.KeyFactory import org.forgerock.json.jose.builders.JwtBuilderFactory import org.forgerock.json.jose.jws.SignedJwt import org.forgerock.json.jose.jws.SigningManager import org.forgerock.http.protocol.Status import java.security.spec.X509EncodedKeySpec //extract jwt from request header def jwt = request.headers['Authorization']?.firstValue if (jwt!=null && jwt.startsWith("Bearer eyJ")) { jwt=jwt.replace("Bearer ", "") try { //parse jwt def sjwt=new JwtBuilderFactory().reconstruct(jwt, SignedJwt.class) //verify jwt signature if (!sjwt.verify(new SigningManager().newRsaSigningHandler(getKey(sjwt.getClaimsSet())))) { throw new Exception("invalid signature") } //check jwt expiration if ((sjwt.getClaimsSet().getExpirationTime()!=null && sjwt.getClaimsSet().getExpirationTime().before(new Date()))) { throw new Exception("signature expired "+sjwt.getClaimsSet().getExpirationTime()) } //add name from JWT claim to header request.headers.put('X-Auth-Username', sjwt.getClaimsSet().getClaim("name")) return next.handle(new org.forgerock.openig.openam.StsContext(context, jwt), request) } catch(Exception e) { e.printStackTrace(); return getErrorResponse(Status.UNAUTHORIZED, e.getMessage()) } } else { //returns 401 status if JWT not present in request return getErrorResponse(Status.UNAUTHORIZED, "Not Authenticated") } return next.handle(context, request) def getErrorResponse(status, message) { def response = new Response() response.status = status response.headers['Content-Type'] = "application/json" response.setEntity("{'error' : '" + message + "'}") return response } def getKey(claims) { def pem=iss[claims.getIssuer()] if (pem != null) { def pemReplaced = pem.replaceFirst("(?m)(?s)^---*BEGIN.*---*\$(.*)^---*END.*---*\$.*", "\$1") byte[] encoded = Base64.getMimeDecoder().decode(pemReplaced) def kf = KeyFactory.getInstance("RSA") def pubKey = kf.generatePublic(new X509EncodedKeySpec(encoded)) println 'got pub key' + pubKey return pubKey } throw new Exception('Unknown issuer') }
Проверим запрос c валидным JWT:
curl -v GET -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzYW1wbGUtc2VydmljZSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJyb2xlIjoibWFuYWdlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNzI2MjM5MDIyfQ.bhzhwj2cY1iYbpx7Mzbukfi1jOCvWP-Pdd9dm3hf7lZDDuokNVDUXU3jvHial4QN-bOTSNCUKVy907hokcVeQaFwbiYoZs485Kr230Z0y9MU6zbDe8yQp68-71TDgJGIZ78YYOKvJTrzCWgWgE_Py1DskG_ViSxfGFlETpFQa056Rk2bty-9iuc_Cx5_Wr6RCcJTG6WYRrBtdWGIFxljEjxSAcJYmGPPA8dHHORDOnmka2OAjWURnsqbz50aI_SrWpnqp4i2eXVA1b5QD5rlsgc_QAqJptghrijBlRPhasTk1N-kXE8Ozboa0FwGfIRr7gNiG-3if7INZe2R5NUCmjlAlywcSfOunWuSzY8tLGTHV2swnQPP8lBXwVJcS5nJMqBNIbcLcFWHk3ryvvtf-LYty_jAY8v1zDe9-DwFPWI0rry8fmiZj7yhAnvX5EHZHvSQtp_zyPpVWvOXFPwasI0jdKoxhWvyJpbmw-D95J5CgJAMfkrWPDQKVt3ipebwnMJStA3xAPPyl28mTBYhJrT6gzIOS3DseoVKK4adn34ZrQi2Hm-wyUtbdulopK739MKM73NYgoFXSJeVUqcy4iw3-In5XmOhdRnUL50TSiaNBbkys8iK7r00HD3kI3CH0GfaPdrcgRgaFXKmVDhX-tEaPJYcuEUTHfQAxWwMdiw' http://localhost:8080/secure * Could not resolve host: GET * Closing connection 0 curl: (6) Could not resolve host: GET * Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 (#1) > GET /secure HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.1.2 > Content-Type: application/json > Accept: application/json > Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzYW1wbGUtc2VydmljZSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJyb2xlIjoibWFuYWdlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNzI2MjM5MDIyfQ.bhzhwj2cY1iYbpx7Mzbukfi1jOCvWP-Pdd9dm3hf7lZDDuokNVDUXU3jvHial4QN-bOTSNCUKVy907hokcVeQaFwbiYoZs485Kr230Z0y9MU6zbDe8yQp68-71TDgJGIZ78YYOKvJTrzCWgWgE_Py1DskG_ViSxfGFlETpFQa056Rk2bty-9iuc_Cx5_Wr6RCcJTG6WYRrBtdWGIFxljEjxSAcJYmGPPA8dHHORDOnmka2OAjWURnsqbz50aI_SrWpnqp4i2eXVA1b5QD5rlsgc_QAqJptghrijBlRPhasTk1N-kXE8Ozboa0FwGfIRr7gNiG-3if7INZe2R5NUCmjlAlywcSfOunWuSzY8tLGTHV2swnQPP8lBXwVJcS5nJMqBNIbcLcFWHk3ryvvtf-LYty_jAY8v1zDe9-DwFPWI0rry8fmiZj7yhAnvX5EHZHvSQtp_zyPpVWvOXFPwasI0jdKoxhWvyJpbmw-D95J5CgJAMfkrWPDQKVt3ipebwnMJStA3xAPPyl28mTBYhJrT6gzIOS3DseoVKK4adn34ZrQi2Hm-wyUtbdulopK739MKM73NYgoFXSJeVUqcy4iw3-In5XmOhdRnUL50TSiaNBbkys8iK7r00HD3kI3CH0GfaPdrcgRgaFXKmVDhX-tEaPJYcuEUTHfQAxWwMdiw > < HTTP/1.1 200 < Date: Wed, 19 Jun 2024 08:59:06 GMT < X-Content-Type-Options: nosniff < X-Frame-Options: deny < Content-Type: application/json < Transfer-Encoding: chunked < * Connection #1 to host localhost left intact {"hello":"John Doe"}
Запрос без JWT:
curl -v GET -H 'Content-Type: application/json' -H 'Accept: application/json' http://localhost:8080/secure * Could not resolve host: GET * Closing connection 0 curl: (6) Could not resolve host: GET * Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 (#1) > GET /secure HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.1.2 > Content-Type: application/json > Accept: application/json > < HTTP/1.1 401 < X-Content-Type-Options: nosniff < X-Frame-Options: deny < Content-Type: application/json < Content-Length: 31 < Date: Wed, 19 Jun 2024 08:59:43 GMT < * Connection #1 to host localhost left intact {'error' : 'Not Authenticated'}
Проверка авторизации
Настроим OpenIG таким образом, чтобы он авторизовывал доступ доступ только пользователям с ролью manager . Роль будем брать claim JWT role. Если в JWT роль отсутствует или отлична от manager, вернем HTTP статус 403 Forbidden.
Добавим в маршрут в фильтр ScriptableFilter параметр allowedRole, чтобы можно было устанавливать допустимую роль в маршруте, не меняя скрипт.
... { "type": "ScriptableFilter", "config": { "type": "application/x-groovy", "file": "jwt.groovy", "args": { "iss": { "sample-service": "${read('/usr/local/openig-config/keys/public_key.pem')}" }, "allowedRole": "manager" } } } ...
Добавим в jwt.groovy проверку роли после проверки срока действия:
//check jwt expiration if ((sjwt.getClaimsSet().getExpirationTime()!=null && sjwt.getClaimsSet().getExpirationTime().before(new Date()))) { throw new Exception("signature expired "+sjwt.getClaimsSet().getExpirationTime()) } //check role if (!sjwt.getClaimsSet().keys().contains("role") || !allowedRole.equals(sjwt.getClaimsSet().getClaim("role", String.class))) { return getErrorResponse(Status.FORBIDDEN, "Forbidden") }
Проверим запрос с валидным JWT
curl -v GET -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzYW1wbGUtc2VydmljZSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJyb2xlIjoibWFuYWdlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNzI2MjM5MDIyfQ.bhzhwj2cY1iYbpx7Mzbukfi1jOCvWP-Pdd9dm3hf7lZDDuokNVDUXU3jvHial4QN-bOTSNCUKVy907hokcVeQaFwbiYoZs485Kr230Z0y9MU6zbDe8yQp68-71TDgJGIZ78YYOKvJTrzCWgWgE_Py1DskG_ViSxfGFlETpFQa056Rk2bty-9iuc_Cx5_Wr6RCcJTG6WYRrBtdWGIFxljEjxSAcJYmGPPA8dHHORDOnmka2OAjWURnsqbz50aI_SrWpnqp4i2eXVA1b5QD5rlsgc_QAqJptghrijBlRPhasTk1N-kXE8Ozboa0FwGfIRr7gNiG-3if7INZe2R5NUCmjlAlywcSfOunWuSzY8tLGTHV2swnQPP8lBXwVJcS5nJMqBNIbcLcFWHk3ryvvtf-LYty_jAY8v1zDe9-DwFPWI0rry8fmiZj7yhAnvX5EHZHvSQtp_zyPpVWvOXFPwasI0jdKoxhWvyJpbmw-D95J5CgJAMfkrWPDQKVt3ipebwnMJStA3xAPPyl28mTBYhJrT6gzIOS3DseoVKK4adn34ZrQi2Hm-wyUtbdulopK739MKM73NYgoFXSJeVUqcy4iw3-In5XmOhdRnUL50TSiaNBbkys8iK7r00HD3kI3CH0GfaPdrcgRgaFXKmVDhX-tEaPJYcuEUTHfQAxWwMdiw' http://localhost:8080/secure * Could not resolve host: GET * Closing connection 0 curl: (6) Could not resolve host: GET * Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 (#1) > GET /secure HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.1.2 > Content-Type: application/json > Accept: application/json > Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzYW1wbGUtc2VydmljZSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJyb2xlIjoibWFuYWdlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNzI2MjM5MDIyfQ.bhzhwj2cY1iYbpx7Mzbukfi1jOCvWP-Pdd9dm3hf7lZDDuokNVDUXU3jvHial4QN-bOTSNCUKVy907hokcVeQaFwbiYoZs485Kr230Z0y9MU6zbDe8yQp68-71TDgJGIZ78YYOKvJTrzCWgWgE_Py1DskG_ViSxfGFlETpFQa056Rk2bty-9iuc_Cx5_Wr6RCcJTG6WYRrBtdWGIFxljEjxSAcJYmGPPA8dHHORDOnmka2OAjWURnsqbz50aI_SrWpnqp4i2eXVA1b5QD5rlsgc_QAqJptghrijBlRPhasTk1N-kXE8Ozboa0FwGfIRr7gNiG-3if7INZe2R5NUCmjlAlywcSfOunWuSzY8tLGTHV2swnQPP8lBXwVJcS5nJMqBNIbcLcFWHk3ryvvtf-LYty_jAY8v1zDe9-DwFPWI0rry8fmiZj7yhAnvX5EHZHvSQtp_zyPpVWvOXFPwasI0jdKoxhWvyJpbmw-D95J5CgJAMfkrWPDQKVt3ipebwnMJStA3xAPPyl28mTBYhJrT6gzIOS3DseoVKK4adn34ZrQi2Hm-wyUtbdulopK739MKM73NYgoFXSJeVUqcy4iw3-In5XmOhdRnUL50TSiaNBbkys8iK7r00HD3kI3CH0GfaPdrcgRgaFXKmVDhX-tEaPJYcuEUTHfQAxWwMdiw > < HTTP/1.1 200 < Date: Wed, 19 Jun 2024 09:05:31 GMT < X-Content-Type-Options: nosniff < X-Frame-Options: deny < Content-Type: application/json < Transfer-Encoding: chunked < * Connection #1 to host localhost left intact {"hello":"John Doe"}%
Проверим запрос с JWT с ролью, отличной от manager
curl -v -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzYW1wbGUtc2VydmljZSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJyb2xlIjoiYmFkIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE3MjYyMzkwMjJ9.UezPgiGOcbp9CMM7hkrbvFsPmFIOnPnph5n60wF9jEWfGAIpS3dgBYvsprsVx0iZaUfhj2GTTLXhQUKrEM08n6jhUBSlwQ22LYBEHhBY57-AwtUhFZVJL8En00tc3HTGLV_El55PyvJvuLRbQ_fZB7rfp27OMPS0y2ciehz21_90TGKvUWUUGJgqDvRPchSKdO7LVa97oigGUp8vi7XiutMxopMLoms63f7FbasbIxMfgEFa48cuJTTcmk7genlPpMX8CBeBUjVriK0452uYdONvSFllqX2rdHwi7idKV-wB0qeUdNq2MDgcVqTrztxRQ8_ezoZVMnn3OLzuSABSpHKtPM3G3uVctY2X408zwOqe86BFvahT1eyBsEmrtszaIL-REy6vy-6P8JJ7iZdD720F1h3VyXj7PWNQiA-v3TumBLpRiML4Clb0SmqpB2iIvPhAz2-ob1w9BBxbvES6n95JEvFDlsv0JqOpvs-ZqQeR1pL7ML0RDR6ZR7xMWE6iVC4hlHEyX5Ufi6CBvkzVLVSnbIPyIBSBc4bzDzqdRkgt139bEdD-htrKWFmGkJKl_yvNcW_rYCkeMmb60km389XUtpiBoSc5CmKkcxrjsarvEMRh-AkIqB5R7Hz0KVKFdp1Hlzj4v1CQKK8eM4Poiq0NoO9IgHFJtgZKMosD7Qc ' http://localhost:8080/secure * Trying 127.0.0.1:8080... * Connected to localhost (127.0.0.1) port 8080 (#0) > GET /secure HTTP/1.1 > Host: localhost:8080 > User-Agent: curl/8.1.2 > Content-Type: application/json > Accept: application/json > Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzYW1wbGUtc2VydmljZSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJyb2xlIjoiYmFkIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE3MjYyMzkwMjJ9.UezPgiGOcbp9CMM7hkrbvFsPmFIOnPnph5n60wF9jEWfGAIpS3dgBYvsprsVx0iZaUfhj2GTTLXhQUKrEM08n6jhUBSlwQ22LYBEHhBY57-AwtUhFZVJL8En00tc3HTGLV_El55PyvJvuLRbQ_fZB7rfp27OMPS0y2ciehz21_90TGKvUWUUGJgqDvRPchSKdO7LVa97oigGUp8vi7XiutMxopMLoms63f7FbasbIxMfgEFa48cuJTTcmk7genlPpMX8CBeBUjVriK0452uYdONvSFllqX2rdHwi7idKV-wB0qeUdNq2MDgcVqTrztxRQ8_ezoZVMnn3OLzuSABSpHKtPM3G3uVctY2X408zwOqe86BFvahT1eyBsEmrtszaIL-REy6vy-6P8JJ7iZdD720F1h3VyXj7PWNQiA-v3TumBLpRiML4Clb0SmqpB2iIvPhAz2-ob1w9BBxbvES6n95JEvFDlsv0JqOpvs-ZqQeR1pL7ML0RDR6ZR7xMWE6iVC4hlHEyX5Ufi6CBvkzVLVSnbIPyIBSBc4bzDzqdRkgt139bEdD-htrKWFmGkJKl_yvNcW_rYCkeMmb60km389XUtpiBoSc5CmKkcxrjsarvEMRh-AkIqB5R7Hz0KVKFdp1Hlzj4v1CQKK8eM4Poiq0NoO9IgHFJtgZKMosD7Qc > > < HTTP/1.1 403 < X-Content-Type-Options: nosniff < X-Frame-Options: deny < Content-Type: application/json < Content-Length: 23 < Date: Wed, 19 Jun 2024 09:06:32 GMT < * Connection #0 to host localhost left intact {'error' : 'Forbidden'}%
