Обеспечение безопасности веб сервисов — одна из важных частей процесса разработки. Если если в инфраструктуре несколько сервисов, то каждый из них должен быть должным образом защищен. Если реализовывать проверки политик безопасности в каждом сервисе, то затраты на разработку и поддержку таких сервисов существенно возрастают. При этом не избежать дублирования кода и ошибок разработки. Поэтому, управление защитой сервисов должно быть централизованным. Далее мы рассмотрим, как организовать централизованную защиту приложений на примере 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'}%  

Комментарии (0)