diff --git a/README.md b/README.md index 1762413..39c655f 100644 --- a/README.md +++ b/README.md @@ -32,20 +32,28 @@ Configuration information is specified in two forms: ### System Properties -| Property | Default | Description | -|-------------------------------|---------------------|----------------------------------------------------------------------------| -| `port` | `8081` | Server port to listen on | -| `okapi_url` | *required* | Where to find Okapi (URL) | -| `secure_store` | `Ephemeral` | Type of secure store to use. Valid: `Ephemeral`, `AwsSsm`, `Vault` | -| `secure_store_props` | `NA` | Path to a properties file specifying secure store configuration | -| `token_cache_ttl_ms` | `3600000` | How long to cache JWTs, in milliseconds (ms) | -| `null_token_cache_ttl_ms` | `30000` | How long to cache login failures (null JWTs), in milliseconds (ms) | -| `token_cache_capacity` | `100` | Max token cache size | -| `patron_id_cache_ttl_ms` | `3600000` | How long to cache patron ID mappings in milliseconds (ms) | -| `null_patron_id_cache_ttl_ms` | `30000` | How long to cache patron lookup failures in milliseconds (ms) | -| `patron_id_cache_capacity` | `1000` | Max token cache size | -| `log_level` | `INFO` | Log4j Log Level | -| `request_timeout_ms` | `30000` | Request Timeout | +| Property | Default | Description | +|----------------------------------|---------------------|----------------------------------------------------------------------------| +| `port` | `8081` | Server port to listen on | +| `okapi_url` | *required* | Where to find Okapi (URL) | +| `secure_store` | `Ephemeral` | Type of secure store to use. Valid: `Ephemeral`, `AwsSsm`, `Vault` | +| `secure_store_props` | `NA` | Path to a properties file specifying secure store configuration | +| `token_cache_ttl_ms` | `3600000` | How long to cache JWTs, in milliseconds (ms) | +| `null_token_cache_ttl_ms` | `30000` | How long to cache login failures (null JWTs), in milliseconds (ms) | +| `token_cache_capacity` | `100` | Max token cache size | +| `patron_id_cache_ttl_ms` | `3600000` | How long to cache patron ID mappings in milliseconds (ms) | +| `null_patron_id_cache_ttl_ms` | `30000` | How long to cache patron lookup failures in milliseconds (ms) | +| `patron_id_cache_capacity` | `1000` | Max token cache size | +| `keycloak_key_cache_ttl_ms` | `3600000` | How long to cache patron ID mappings in milliseconds (ms) | +| `null_keycloak_key_cache_ttl_ms` | `30000` | How long to cache patron lookup failures in milliseconds (ms) | +| `keycloak_key_cache_capacity` | `1000` | Max token cache size | +| `log_level` | `INFO` | Log4j Log Level | +| `request_timeout_ms` | `30000` | Request Timeout | + +### Env variables for secure requests +| Property | Default | Description | +|----------|----------------|------------------------------------------| +| `KC_URL` | `` | Keycloak url for secure token validation | ### Env variables for TLS configuration for Http server diff --git a/pom.xml b/pom.xml index 0e833e4..7423797 100644 --- a/pom.xml +++ b/pom.xml @@ -86,8 +86,13 @@ io.jsonwebtoken - jjwt-api - 0.11.5 + jjwt-impl + 0.12.6 + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 com.fasterxml.jackson.core diff --git a/ramls/edge-patron.raml b/ramls/edge-patron.raml index 6973136..e994c65 100644 --- a/ramls/edge-patron.raml +++ b/ramls/edge-patron.raml @@ -139,6 +139,324 @@ types: body: text/plain: example: internal server error, contact administrator + get: + description: (Secure) Return account details for the user provided in access token(x-okapi-token) + queryParameters: + includeLoans: + description: | + Indicates whether or not to include the loans array in + the response + required: false + type: boolean + default: false + includeCharges: + description: | + Indicates whether or not to include the charges array in + the response + required: false + type: boolean + default: false + includeHolds: + description: | + Indicates whether or not to include the holds array in + the response + required: false + type: boolean + default: false + apikey: + description: "API Key" + type: string + sortBy: + description: | + Part of CQL query, indicates the order of records within the lists of holds, charges, loans + example: item.title/sort.ascending + required: false + type: string + offset: + description: | + Skip over a number of elements by specifying an offset value for the query + type: integer + required: false + example: 1 + minimum: 0 + maximum: 2147483647 + limit: + description: | + Limit the number of elements returned in the response + type: integer + required: false + example: 10 + minimum: 0 + maximum: 2147483647 + responses: + 200: + description: Returns the user account info + body: + application/json: + type: account + example: !include examples/account.json + 400: + description: Bad request + body: + text/plain: + example: unable to process request -- constraint violation + 401: + description: Not authorized to perform requested action + body: + text/plain: + example: unable to get account -- unauthorized + 404: + description: Item with a given ID not found + body: + text/plain: + example: account not found + 403: + description: Access Denied + body: + text/plain: + example: Access Denied + 500: + description: Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator + /item: + /{itemId}: + uriParameters: + itemId: + description: The UUID of a FOLIO item + type: string + pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$ + /hold: + displayName: Hold Management + description: (Secure) Services that provide hold management + post: + description: | + (Secure) Creates a hold request on an existing item for the user + queryParameters: + apikey: + description: "API Key" + type: string + body: + application/json: + type: hold + example: !include examples/hold.json + responses: + 201: + description: | + Returns data for a new hold request on the specified item + body: + application/json: + type: hold + example: !include examples/hold.json + 400: + description: Bad request + body: + text/plain: + example: unable to process request -- constraint violation + 401: + description: Not authorized to perform requested action + body: + text/plain: + example: unable to create hold -- unauthorized + 404: + description: Item with a given ID not found + body: + text/plain: + example: item not found + 403: + description: Access Denied + body: + text/plain: + example: Access Denied + 500: + description: | + Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator + /allowed-service-points: + displayName: Allowed service points + description: Service that provides a list of allowed pickup service points + get: + description: | + (Secure) Returns a list of pickup service points allowed for a particular patron and instance + queryParameters: + apikey: + description: "API Key" + type: string + body: + application/json: + type: allowedServicePoints + example: !include examples/allowed-service-points-response.json + responses: + 200: + description: | + Successfully returns a list of allowed service points + body: + application/json: + type: allowedServicePoints + example: !include examples/allowed-service-points-response.json + 422: + description: Validation error + body: + application/json: + type: errors + 500: + description: | + Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator + /instance: + /{instanceId}: + uriParameters: + instanceId: + description: The UUID of a FOLIO instance + type: string + pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$ + /hold: + displayName: Hold Management + description: Services that provide hold management + post: + description: | + (Secure) Creates a hold request on an existing item by instance ID for the user + queryParameters: + apikey: + description: "API Key" + type: string + body: + application/json: + type: hold + example: !include examples/hold.json + responses: + 201: + description: | + Returns data for a new hold request on the selected item + body: + application/json: + type: hold + example: !include examples/hold.json + 400: + description: Bad request + body: + text/plain: + example: unable to process request -- constraint violation + 401: + description: Not authorized to perform requested action + body: + text/plain: + example: unable to create hold -- unauthorized + 404: + description: Instance with a given ID not found + body: + text/plain: + example: item not found + 403: + description: Access Denied + body: + text/plain: + example: Access Denied + 422: + description: Validation error + body: + application/json: + type : errors + 500: + description: | + Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator + /allowed-service-points: + displayName: Allowed service points + description: Service that provides a list of allowed pickup service points + get: + description: | + (Secure) Returns a list of pickup service points allowed for a particular patron and instance + queryParameters: + apikey: + description: "API Key" + type: string + body: + application/json: + type: allowedServicePoints + example: !include examples/allowed-service-points-response.json + responses: + 200: + description: | + Successfully returns a list of allowed service points + body: + application/json: + type: allowedServicePoints + example: !include examples/allowed-service-points-response.json + 422: + description: Validation error + body: + application/json: + type: errors + 500: + description: | + Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator + /hold: + displayName: Hold Management + description: Services that provide hold management + /{holdId}: + displayName: Hold Management By Id + description: Services that provide hold management by Id + uriParameters: + holdId: + description: The UUID of a FOLIO hold request + type: string + pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$ + /cancel: + post: + description: (Secure) Removes the specified hold request + queryParameters: + apikey: + description: "API Key" + type: string + body: + application/json: + type: hold-cancellation + example: !include examples/hold-cancellation.json + responses: + 201: + description: The specified hold request was removed + body: + application/json: + type: hold + example: !include examples/hold.json + 400: + description: Bad request + body: + text/plain: + example: | + unable to process request -- constraint violation + 401: + description: Not authorized to perform requested action + body: + text/plain: + example: unable to cancel hold -- unauthorized + 404: + description: hold with a given ID not found + body: + text/plain: + example: hold not found + 403: + description: Access denied + body: + text/plain: + example: access denied + 500: + description: | + Internal server error, e.g. due to misconfiguration + body: + text/plain: + example: internal server error, contact administrator /external-patrons: displayName: Get Accounts of External Patrons description: Get accounts of external patrons based on flag diff --git a/src/main/java/org/folio/edge/patron/Constants.java b/src/main/java/org/folio/edge/patron/Constants.java index 48c960c..7b60082 100644 --- a/src/main/java/org/folio/edge/patron/Constants.java +++ b/src/main/java/org/folio/edge/patron/Constants.java @@ -4,15 +4,24 @@ public class Constants { + public static final String KEYCLOAK_URL = "KC_URL"; public static final String SYS_PATRON_ID_CACHE_TTL_MS = "patron_id_cache_ttl_ms"; public static final String SYS_NULL_PATRON_ID_CACHE_TTL_MS = "null_patron_id_cache_ttl_ms"; public static final String SYS_PATRON_ID_CACHE_CAPACITY = "patron_id_cache_capacity"; + public static final String SYS_KEYCLOAK_KEY_CACHE_TTL_MS = "keycloak_key_cache_ttl_ms"; + public static final String SYS_NULL_KEYCLOAK_KEY_CACHE_TTL_MS = "null_keycloak_key_cache_ttl_ms"; + public static final String SYS_KEYCLOAK_KEY_CACHE_CAPACITY = "keycloak_key_cache_capacity"; public static final String PARAM_EXPIRED = "expired"; + public static final String VIP_CLAIM = "vip"; + public static final String EXTERNAL_SYSTEM_ID_CLAIM = "externalSystemId"; public static final String DEFAULT_CURRENCY_CODE = Currency.getInstance("USD").getCurrencyCode(); public static final long DEFAULT_PATRON_ID_CACHE_TTL_MS = 60 * 60 * 1000L; public static final long DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS = 30 * 1000L; public static final int DEFAULT_PATRON_ID_CACHE_CAPACITY = 1000; + public static final long DEFAULT_KEYCLOAK_KEY_CACHE_TTL_MS = 60 * 60 * 1000L; + public static final long DEFAULT_NULL_KEYCLOAK_KEY_CACHE_TTL_MS = 30 * 1000L; + public static final int DEFAULT_KEYCLOAK_KEY_CACHE_CAPACITY = 50; public static final String PARAM_SORT_BY = "sortBy"; public static final String PARAM_LIMIT = "limit"; diff --git a/src/main/java/org/folio/edge/patron/MainVerticle.java b/src/main/java/org/folio/edge/patron/MainVerticle.java index daab13f..324d7c0 100644 --- a/src/main/java/org/folio/edge/patron/MainVerticle.java +++ b/src/main/java/org/folio/edge/patron/MainVerticle.java @@ -1,21 +1,31 @@ package org.folio.edge.patron; +import static org.folio.edge.patron.Constants.DEFAULT_KEYCLOAK_KEY_CACHE_CAPACITY; +import static org.folio.edge.patron.Constants.DEFAULT_KEYCLOAK_KEY_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.DEFAULT_NULL_KEYCLOAK_KEY_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.DEFAULT_PATRON_ID_CACHE_CAPACITY; +import static org.folio.edge.patron.Constants.DEFAULT_PATRON_ID_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.KEYCLOAK_URL; +import static org.folio.edge.patron.Constants.SYS_KEYCLOAK_KEY_CACHE_CAPACITY; +import static org.folio.edge.patron.Constants.SYS_KEYCLOAK_KEY_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.SYS_NULL_KEYCLOAK_KEY_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.SYS_NULL_PATRON_ID_CACHE_TTL_MS; +import static org.folio.edge.patron.Constants.SYS_PATRON_ID_CACHE_CAPACITY; +import static org.folio.edge.patron.Constants.SYS_PATRON_ID_CACHE_TTL_MS; + import io.vertx.core.http.HttpMethod; import io.vertx.ext.web.Router; +import io.vertx.ext.web.client.WebClient; import io.vertx.ext.web.handler.BodyHandler; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.folio.edge.core.EdgeVerticleHttp; import org.folio.edge.core.utils.OkapiClientFactory; import org.folio.edge.core.utils.OkapiClientFactoryInitializer; +import org.folio.edge.patron.cache.KeycloakPublicKeyCache; import org.folio.edge.patron.cache.PatronIdCache; - -import static org.folio.edge.patron.Constants.DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS; -import static org.folio.edge.patron.Constants.DEFAULT_PATRON_ID_CACHE_CAPACITY; -import static org.folio.edge.patron.Constants.DEFAULT_PATRON_ID_CACHE_TTL_MS; -import static org.folio.edge.patron.Constants.SYS_NULL_PATRON_ID_CACHE_TTL_MS; -import static org.folio.edge.patron.Constants.SYS_PATRON_ID_CACHE_CAPACITY; -import static org.folio.edge.patron.Constants.SYS_PATRON_ID_CACHE_TTL_MS; +import org.folio.edge.patron.utils.KeycloakClient; public class MainVerticle extends EdgeVerticleHttp { @@ -23,29 +33,52 @@ public class MainVerticle extends EdgeVerticleHttp { public MainVerticle() { super(); + initializePatronIdCache(); + initializeKeycloakKeyCache(); + } + private void initializePatronIdCache() { final String patronIdCacheTtlMs = System.getProperty(SYS_PATRON_ID_CACHE_TTL_MS); final long cacheTtlMs = patronIdCacheTtlMs != null ? Long.parseLong(patronIdCacheTtlMs) - : DEFAULT_PATRON_ID_CACHE_TTL_MS; - logger.info("Using patronId cache TTL (ms): " + patronIdCacheTtlMs); + : DEFAULT_PATRON_ID_CACHE_TTL_MS; final String nullTokenCacheTtlMs = System.getProperty(SYS_NULL_PATRON_ID_CACHE_TTL_MS); final long failureCacheTtlMs = nullTokenCacheTtlMs != null ? Long.parseLong(nullTokenCacheTtlMs) - : DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS; - logger.info("Using patronId cache TTL (ms): " + failureCacheTtlMs); + : DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS; final String patronIdCacheCapacity = System.getProperty(SYS_PATRON_ID_CACHE_CAPACITY); final int cacheCapacity = patronIdCacheCapacity != null ? Integer.parseInt(patronIdCacheCapacity) - : DEFAULT_PATRON_ID_CACHE_CAPACITY; - logger.info("Using patronId cache capacity: " + patronIdCacheCapacity); + : DEFAULT_PATRON_ID_CACHE_CAPACITY; PatronIdCache.initialize(cacheTtlMs, failureCacheTtlMs, cacheCapacity); } + private void initializeKeycloakKeyCache() { + final String keycloakKeyCacheTtlMs = retriveProperty(SYS_KEYCLOAK_KEY_CACHE_TTL_MS); + final long cacheTtlMs = keycloakKeyCacheTtlMs != null ? Long.parseLong(keycloakKeyCacheTtlMs) + : DEFAULT_KEYCLOAK_KEY_CACHE_TTL_MS; + + final String nullTokenCacheTtlMs = retriveProperty(SYS_NULL_KEYCLOAK_KEY_CACHE_TTL_MS); + final long failureCacheTtlMs = nullTokenCacheTtlMs != null ? Long.parseLong(nullTokenCacheTtlMs) + : DEFAULT_NULL_KEYCLOAK_KEY_CACHE_TTL_MS; + + final String keycloakKeyCacheCapacity = retriveProperty(SYS_KEYCLOAK_KEY_CACHE_CAPACITY); + final int cacheCapacity = keycloakKeyCacheCapacity != null ? Integer.parseInt(keycloakKeyCacheCapacity) + : DEFAULT_KEYCLOAK_KEY_CACHE_CAPACITY; + + KeycloakPublicKeyCache.initialize(cacheTtlMs, failureCacheTtlMs, cacheCapacity); + } + @Override public Router defineRoutes() { OkapiClientFactory ocf = OkapiClientFactoryInitializer.createInstance(vertx, config()); - PatronHandler patronHandler = new PatronHandler(secureStore, ocf); + final String keycloakUrl = retriveProperty(KEYCLOAK_URL); + if(keycloakUrl == null || keycloakUrl.isEmpty()) { + logger.warn("Keycloak url is not defined. Secure endpoints will not work"); + } + logger.info("Using keycloak url: {}", keycloakUrl); + KeycloakClient keycloakClient = new KeycloakClient(keycloakUrl, WebClient.create(vertx)); + PatronHandler patronHandler = new PatronHandler(secureStore, ocf, keycloakClient); Router router = Router.router(vertx); router.route().handler(BodyHandler.create()); @@ -56,30 +89,56 @@ public Router defineRoutes() { router.route(HttpMethod.GET, "/patron/account/:patronId") .handler(patronHandler::handleGetAccount); + router.route(HttpMethod.GET, "/patron/account") + .handler(patronHandler::handleSecureGetAccount); + router.route(HttpMethod.POST, "/patron/account/:patronId/item/:itemId/renew") .handler(patronHandler::handleRenew); router.route(HttpMethod.POST, "/patron/account/:patronId/item/:itemId/hold") .handler(patronHandler::handlePlaceItemHold); + router.route(HttpMethod.POST, "/patron/account/item/:itemId/hold") + .handler(patronHandler::handleSecurePlaceItemHold); + router.route(HttpMethod.POST, "/patron") .handler(patronHandler::handlePostPatronRequest); router.route(HttpMethod.POST, "/patron/account/:patronId/instance/:instanceId/hold") .handler(patronHandler::handlePlaceInstanceHold); - router.route(HttpMethod.GET, "/patron/account/:patronId/instance/:instanceId/" + - "allowed-service-points").handler(patronHandler::handleGetAllowedServicePointsForInstance); + router.route(HttpMethod.POST, "/patron/account/instance/:instanceId/hold") + .handler(patronHandler::handleSecurePlaceInstanceHold); + + router.route(HttpMethod.GET, "/patron/account/:patronId/instance/:instanceId/allowed-service-points") + .handler(patronHandler::handleGetAllowedServicePointsForInstance); + + router.route(HttpMethod.GET, "/patron/account/instance/:instanceId/allowed-service-points") + .handler(patronHandler::handleSecureGetAllowedServicePointsForInstance); + + router.route(HttpMethod.GET, "/patron/account/:patronId/item/:itemId/allowed-service-points") + .handler(patronHandler::handleGetAllowedServicePointsForItem); - router.route(HttpMethod.GET, "/patron/account/:patronId/item/:itemId/" + - "allowed-service-points").handler(patronHandler::handleGetAllowedServicePointsForItem); + router.route(HttpMethod.GET, "/patron/account/item/:itemId/allowed-service-points") + .handler(patronHandler::handleSecureGetAllowedServicePointsForItem); router.route(HttpMethod.POST, "/patron/account/:patronId/hold/:holdId/cancel") .handler(patronHandler::handleCancelHold); + router.route(HttpMethod.POST, "/patron/account/hold/:holdId/cancel") + .handler(patronHandler::handleSecureCancelHold); + router.route(HttpMethod.GET, "/patron/registration-status") .handler(patronHandler::handleGetPatronRegistrationStatus); return router; } + + private String retriveProperty(String name) { + var property = System.getProperty(name); + if (property == null) { + property = System.getenv().get(name); + } + return property; + } } diff --git a/src/main/java/org/folio/edge/patron/PatronHandler.java b/src/main/java/org/folio/edge/patron/PatronHandler.java index 4c4c362..7b71aa0 100644 --- a/src/main/java/org/folio/edge/patron/PatronHandler.java +++ b/src/main/java/org/folio/edge/patron/PatronHandler.java @@ -1,5 +1,29 @@ package org.folio.edge.patron; +import static org.folio.edge.core.Constants.APPLICATION_JSON; +import static org.folio.edge.core.Constants.X_OKAPI_TENANT; +import static org.folio.edge.core.Constants.X_OKAPI_TOKEN; +import static org.folio.edge.patron.Constants.EXTERNAL_SYSTEM_ID_CLAIM; +import static org.folio.edge.patron.Constants.FIELD_EXPIRATION_DATE; +import static org.folio.edge.patron.Constants.FIELD_REQUEST_DATE; +import static org.folio.edge.patron.Constants.MSG_ACCESS_DENIED; +import static org.folio.edge.patron.Constants.MSG_HOLD_NOBODY; +import static org.folio.edge.patron.Constants.MSG_INTERNAL_SERVER_ERROR; +import static org.folio.edge.patron.Constants.MSG_REQUEST_TIMEOUT; +import static org.folio.edge.patron.Constants.PARAM_EMAIL_ID; +import static org.folio.edge.patron.Constants.PARAM_HOLD_ID; +import static org.folio.edge.patron.Constants.PARAM_INCLUDE_CHARGES; +import static org.folio.edge.patron.Constants.PARAM_INCLUDE_HOLDS; +import static org.folio.edge.patron.Constants.PARAM_INCLUDE_LOANS; +import static org.folio.edge.patron.Constants.PARAM_INSTANCE_ID; +import static org.folio.edge.patron.Constants.PARAM_ITEM_ID; +import static org.folio.edge.patron.Constants.PARAM_LIMIT; +import static org.folio.edge.patron.Constants.PARAM_OFFSET; +import static org.folio.edge.patron.Constants.PARAM_PATRON_ID; +import static org.folio.edge.patron.Constants.PARAM_SORT_BY; +import static org.folio.edge.patron.Constants.VIP_CLAIM; +import static org.folio.edge.patron.model.HoldCancellationValidator.validateCancelHoldRequest; + import com.amazonaws.util.StringUtils; import com.fasterxml.jackson.core.JsonProcessingException; import io.vertx.core.buffer.Buffer; @@ -9,6 +33,15 @@ import io.vertx.core.json.JsonObject; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.client.HttpResponse; +import java.text.SimpleDateFormat; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TimeZone; +import java.util.function.Consumer; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.folio.edge.core.Handler; @@ -19,46 +52,20 @@ import org.folio.edge.patron.model.error.Error; import org.folio.edge.patron.model.error.ErrorMessage; import org.folio.edge.patron.model.error.Errors; +import org.folio.edge.patron.utils.KeycloakClient; +import org.folio.edge.patron.utils.KeycloakTokenHelper; import org.folio.edge.patron.utils.PatronIdHelper; import org.folio.edge.patron.utils.PatronOkapiClient; -import java.text.SimpleDateFormat; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.TimeZone; - -import static org.folio.edge.core.Constants.APPLICATION_JSON; -import static org.folio.edge.patron.Constants.FIELD_EXPIRATION_DATE; -import static org.folio.edge.patron.Constants.FIELD_REQUEST_DATE; -import static org.folio.edge.patron.Constants.MSG_ACCESS_DENIED; -import static org.folio.edge.patron.Constants.MSG_HOLD_NOBODY; -import static org.folio.edge.patron.Constants.MSG_INTERNAL_SERVER_ERROR; -import static org.folio.edge.patron.Constants.MSG_REQUEST_TIMEOUT; -import static org.folio.edge.patron.Constants.PARAM_EMAIL_ID; -import static org.folio.edge.patron.Constants.PARAM_HOLD_ID; -import static org.folio.edge.patron.Constants.PARAM_INCLUDE_CHARGES; -import static org.folio.edge.patron.Constants.PARAM_INCLUDE_HOLDS; -import static org.folio.edge.patron.Constants.PARAM_INCLUDE_LOANS; -import static org.folio.edge.patron.Constants.PARAM_INSTANCE_ID; -import static org.folio.edge.patron.Constants.PARAM_ITEM_ID; -import static org.folio.edge.patron.Constants.PARAM_LIMIT; -import static org.folio.edge.patron.Constants.PARAM_OFFSET; -import static org.folio.edge.patron.Constants.PARAM_PATRON_ID; -import static org.folio.edge.patron.Constants.PARAM_SORT_BY; -import static org.folio.edge.patron.model.HoldCancellationValidator.validateCancelHoldRequest; - public class PatronHandler extends Handler { public static final String WRONG_INTEGER_PARAM_MESSAGE = "'%s' parameter is incorrect." + " parameter value {%s} is not valid: must be an integer, greater than or equal to 0"; private static final Logger logger = LogManager.getLogger(Handler.class); - - public PatronHandler(SecureStore secureStore, OkapiClientFactory ocf) { + private final KeycloakClient keycloakClient; + public PatronHandler(SecureStore secureStore, OkapiClientFactory ocf, KeycloakClient keycloakClient) { super(secureStore, ocf); + this.keycloakClient = keycloakClient; } @Override @@ -103,6 +110,41 @@ protected void handleCommon(RoutingContext ctx, String[] requiredParams, String[ }); } + private void handleSecureCommon(RoutingContext ctx, Consumer handler) { + var token = ctx.request().getHeader(X_OKAPI_TOKEN); + var tenant = ctx.request().getHeader(X_OKAPI_TENANT); + + if (token == null || token.isEmpty()) { + badRequest(ctx, "Missing access token"); + return; + } + + if (tenant == null || tenant.isEmpty()) { + badRequest(ctx, "Missing tenant id"); + return; + } + KeycloakTokenHelper.getClaimsFromToken(token, tenant, keycloakClient) + .onSuccess(claims -> { + var vip = claims.get(VIP_CLAIM, Boolean.class); + var externalSystemId = claims.get(EXTERNAL_SYSTEM_ID_CLAIM, String.class); + if (vip == null || externalSystemId == null) { + logger.error("Token doesn't contain required claims"); + badRequest(ctx, "Token doesn't contain required claims"); + return; + } + if (!vip) { + accessDenied(ctx, "Patron is not allowed to call secure endpoints"); + return; + } + ctx.request().params().add(PARAM_PATRON_ID, externalSystemId); + handler.accept(ctx); + }) + .onFailure(ex -> { + logger.error("Failed to get claims from token", ex); + badRequest(ctx, "Failed to validate access token"); + }); + } + public void handleGetAccount(RoutingContext ctx) { handleCommon(ctx, new String[] {}, @@ -128,6 +170,10 @@ public void handleGetAccount(RoutingContext ctx) { }); } + public void handleSecureGetAccount(RoutingContext ctx) { + handleSecureCommon(ctx, this::handleGetAccount); + } + public void handleRenew(RoutingContext ctx) { handleCommon(ctx, new String[] { PARAM_ITEM_ID }, @@ -157,6 +203,10 @@ public void handlePlaceItemHold(RoutingContext ctx) { t -> handleProxyException(ctx, t))); } + public void handleSecurePlaceItemHold(RoutingContext ctx) { + handleSecureCommon(ctx, this::handlePlaceItemHold); + } + public void handlePostPatronRequest(RoutingContext ctx) { if (ctx.body().asJsonObject() == null) { logger.warn("handlePostPatronRequest:: missing body found"); @@ -202,6 +252,10 @@ public void handleCancelHold(RoutingContext ctx) { ); } + public void handleSecureCancelHold(RoutingContext ctx) { + handleSecureCommon(ctx, this::handleCancelHold); + } + public void handlePlaceInstanceHold(RoutingContext ctx) { if (ctx.body().asJsonObject() == null) { badRequest(ctx, MSG_HOLD_NOBODY); @@ -219,6 +273,10 @@ public void handlePlaceInstanceHold(RoutingContext ctx) { t -> handleProxyException(ctx, t))); } + public void handleSecurePlaceInstanceHold(RoutingContext ctx) { + handleSecureCommon(ctx, this::handlePlaceInstanceHold); + } + public void handleGetAllowedServicePointsForInstance(RoutingContext ctx) { handleCommon(ctx, new String[] { PARAM_PATRON_ID, PARAM_INSTANCE_ID }, @@ -230,6 +288,10 @@ public void handleGetAllowedServicePointsForInstance(RoutingContext ctx) { t -> handleProxyException(ctx, t))); } + public void handleSecureGetAllowedServicePointsForInstance(RoutingContext ctx) { + handleSecureCommon(ctx, this::handleGetAllowedServicePointsForInstance); + } + public void handleGetAllowedServicePointsForItem(RoutingContext ctx) { handleCommon(ctx, @@ -242,6 +304,10 @@ public void handleGetAllowedServicePointsForItem(RoutingContext ctx) { t -> handleProxyException(ctx, t))); } + public void handleSecureGetAllowedServicePointsForItem(RoutingContext ctx) { + handleSecureCommon(ctx, this::handleGetAllowedServicePointsForItem); + } + public void handleGetPatronRegistrationStatus(RoutingContext ctx) { logger.debug("handleGetPatronRegistrationStatus:: Fetching patron registration"); String emailId = ctx.request().getParam(PARAM_EMAIL_ID); diff --git a/src/main/java/org/folio/edge/patron/cache/KeycloakPublicKeyCache.java b/src/main/java/org/folio/edge/patron/cache/KeycloakPublicKeyCache.java new file mode 100644 index 0000000..80f0bf9 --- /dev/null +++ b/src/main/java/org/folio/edge/patron/cache/KeycloakPublicKeyCache.java @@ -0,0 +1,79 @@ +package org.folio.edge.patron.cache; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.edge.core.cache.Cache; +import org.folio.edge.core.cache.Cache.Builder; +import org.folio.edge.core.cache.Cache.CacheValue; + +public class KeycloakPublicKeyCache { + + private static final Logger logger = LogManager.getLogger(KeycloakPublicKeyCache.class); + + private static KeycloakPublicKeyCache instance = null; + + private Cache cache; + + private KeycloakPublicKeyCache(long ttl, long nullTokenTtl, int capacity) { + logger.info("Using TTL: {}", ttl); + logger.info("Using null token TTL: {}", nullTokenTtl); + logger.info("Using capacity: {}", capacity); + cache = new Builder() + .withTTL(ttl) + .withNullValueTTL(nullTokenTtl) + .withCapacity(capacity) + .build(); + } + + /** + * Get the KeycloakPublicKeyCache singleton. the singleton must be initialized before + * calling this method. + * + * @see {@link #initialize(long, long, int)} + * + * @return the KeycloakPublicKeyCache singleton instance. + */ + public static synchronized KeycloakPublicKeyCache getInstance() { + if (instance == null) { + throw new KeycloakPublicKeyCacheNotInitializedException( + "You must call KeycloakPublicKeyCache.initialize(ttl, capacity) before you can get the singleton instance"); + } + return instance; + } + + /** + * Creates a new KeycloakPublicKeyCache instance, replacing the existing one if it + * already exists; in which case all pre-existing cache entries will be lost. + * + * @param ttl + * cache entry time to live in ms + * @param capacity + * maximum number of entries this cache will hold before pruning + * @return the new KeycloakPublicKeyCache singleton instance + */ + public static synchronized KeycloakPublicKeyCache initialize(long ttl, long nullValueTtl, int capacity) { + if (instance != null) { + logger.warn("Reinitializing cache. All cached entries will be lost"); + } + instance = new KeycloakPublicKeyCache(ttl, nullValueTtl, capacity); + return instance; + } + + public String get(String realm) { + return cache.get(realm); + } + + public CacheValue put(String realm, String publicKey) { + return cache.put(realm, publicKey); + } + + public static class KeycloakPublicKeyCacheNotInitializedException extends RuntimeException { + + private static final long serialVersionUID = -8622978462142499585L; + + public KeycloakPublicKeyCacheNotInitializedException(String msg) { + super(msg); + } + } + +} diff --git a/src/main/java/org/folio/edge/patron/cache/PatronIdCache.java b/src/main/java/org/folio/edge/patron/cache/PatronIdCache.java index 13bc25a..c392800 100644 --- a/src/main/java/org/folio/edge/patron/cache/PatronIdCache.java +++ b/src/main/java/org/folio/edge/patron/cache/PatronIdCache.java @@ -15,9 +15,9 @@ public class PatronIdCache { private Cache cache; private PatronIdCache(long ttl, long nullTokenTtl, int capacity) { - logger.info("Using TTL: {0}", ttl); - logger.info("Using null token TTL: {0}", nullTokenTtl); - logger.info("Using capcity: {0}", capacity); + logger.info("Using TTL: {}", ttl); + logger.info("Using null token TTL: {}", nullTokenTtl); + logger.info("Using capacity: {}", capacity); cache = new Builder() .withTTL(ttl) .withNullValueTTL(nullTokenTtl) diff --git a/src/main/java/org/folio/edge/patron/utils/KeycloakClient.java b/src/main/java/org/folio/edge/patron/utils/KeycloakClient.java new file mode 100644 index 0000000..4440d70 --- /dev/null +++ b/src/main/java/org/folio/edge/patron/utils/KeycloakClient.java @@ -0,0 +1,36 @@ +package org.folio.edge.patron.utils; + +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.ext.web.client.WebClient; +import org.apache.http.HttpStatus; +import org.folio.edge.patron.cache.KeycloakPublicKeyCache; + +public class KeycloakClient { + + private static final String REALM_INFO_URI = "/realms/%s/protocol/openid-connect/certs"; + private final String keycloakUrl; + private final WebClient webClient; + + public KeycloakClient(String keycloakUrl, WebClient webClient) { + this.keycloakUrl = keycloakUrl; + this.webClient = webClient; + } + + public Future getPublicKeys(String realm) { + Promise promise = Promise.promise(); + String uri = String.format(REALM_INFO_URI, realm); + webClient.getAbs(keycloakUrl + uri).send() + .onSuccess(response -> { + if (HttpStatus.SC_OK == response.statusCode()) { + var body = response.bodyAsString(); + KeycloakPublicKeyCache.getInstance().put(realm, body); + promise.complete(body); + } else { + promise.fail(new RuntimeException("Request failed with status: " + response.statusCode())); + } + }) + .onFailure(promise::fail); + return promise.future(); + } +} diff --git a/src/main/java/org/folio/edge/patron/utils/KeycloakTokenHelper.java b/src/main/java/org/folio/edge/patron/utils/KeycloakTokenHelper.java new file mode 100644 index 0000000..4ac29f0 --- /dev/null +++ b/src/main/java/org/folio/edge/patron/utils/KeycloakTokenHelper.java @@ -0,0 +1,70 @@ +package org.folio.edge.patron.utils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.Locator; +import io.jsonwebtoken.ProtectedHeader; +import io.jsonwebtoken.security.JwkSet; +import io.jsonwebtoken.security.Jwks; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import java.security.Key; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.edge.patron.cache.KeycloakPublicKeyCache; + +public class KeycloakTokenHelper { + + private static final Logger logger = LogManager.getLogger(KeycloakTokenHelper.class); + + private KeycloakTokenHelper() { + } + + public static Future getClaimsFromToken(String accessToken, String realm, KeycloakClient client) { + Promise promise = Promise.promise(); + getKeycloakPublicKey(realm, client).onSuccess(keys -> { + var jwks = Jwks.setParser().build().parse(keys); + var parser = Jwts.parser().keyLocator(locateKey(jwks)).build(); + try { + var claims = parser.parseSignedClaims(accessToken).getPayload(); + promise.complete(claims); + } catch (Exception ex) { + promise.fail(ex); + } + }).onFailure(ex -> { + logger.error("Failed to get public key from keycloak", ex); + promise.fail(ex); + }); + return promise.future(); + } + + private static Locator locateKey(JwkSet jwks) { + return header -> { + if (header instanceof ProtectedHeader ph) { + var key = jwks.getKeys().stream().filter(jwk -> jwk.getId().equals(ph.getKeyId())).findFirst(); + if (key.isEmpty()) { + return null; + } + return key.get().toKey(); + } else { + return null; + } + }; + } + + private static Future getKeycloakPublicKey(String realm, KeycloakClient client) { + String publicKey = null; + try { + var cache = KeycloakPublicKeyCache.getInstance(); + publicKey = cache.get(realm); + } catch (KeycloakPublicKeyCache.KeycloakPublicKeyCacheNotInitializedException ex) { + logger.warn("Keycloak cache not initialized"); + } + if (publicKey != null) { + return Future.succeededFuture(publicKey); + } + + return client.getPublicKeys(realm); + } + +} diff --git a/src/test/java/org/folio/edge/patron/MainVerticleTest.java b/src/test/java/org/folio/edge/patron/MainVerticleTest.java index 9eaadaa..ecc3f70 100644 --- a/src/test/java/org/folio/edge/patron/MainVerticleTest.java +++ b/src/test/java/org/folio/edge/patron/MainVerticleTest.java @@ -1,41 +1,5 @@ package org.folio.edge.patron; -import io.restassured.RestAssured; -import io.restassured.config.DecoderConfig; -import io.restassured.config.DecoderConfig.ContentDecoder; -import io.restassured.response.Response; -import io.vertx.core.DeploymentOptions; -import io.vertx.core.Vertx; -import io.vertx.core.json.JsonObject; -import io.vertx.ext.unit.TestContext; -import io.vertx.ext.unit.junit.VertxUnitRunner; -import org.apache.commons.lang3.StringUtils; -import org.apache.http.HttpHeaders; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.folio.edge.core.utils.ApiKeyUtils; -import org.folio.edge.core.utils.test.TestUtils; -import org.folio.edge.patron.model.Account; -import org.folio.edge.patron.model.Hold; -import org.folio.edge.patron.model.Loan; -import org.folio.edge.patron.model.error.ErrorMessage; -import org.folio.edge.patron.utils.PatronMockOkapi; -import org.junit.After; -import org.junit.AfterClass; -import org.junit.Assert; -import org.junit.BeforeClass; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - import static org.folio.edge.core.Constants.APPLICATION_JSON; import static org.folio.edge.core.Constants.DAY_IN_MILLIS; import static org.folio.edge.core.Constants.SYS_LOG_LEVEL; @@ -45,6 +9,9 @@ import static org.folio.edge.core.Constants.SYS_RESPONSE_COMPRESSION; import static org.folio.edge.core.Constants.SYS_SECURE_STORE_PROP_FILE; import static org.folio.edge.core.Constants.TEXT_PLAIN; +import static org.folio.edge.core.Constants.X_OKAPI_TENANT; +import static org.folio.edge.core.Constants.X_OKAPI_TOKEN; +import static org.folio.edge.patron.Constants.KEYCLOAK_URL; import static org.folio.edge.patron.Constants.MSG_ACCESS_DENIED; import static org.folio.edge.patron.Constants.MSG_HOLD_NOBODY; import static org.folio.edge.patron.Constants.MSG_REQUEST_TIMEOUT; @@ -69,6 +36,42 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; +import io.restassured.RestAssured; +import io.restassured.config.DecoderConfig; +import io.restassured.config.DecoderConfig.ContentDecoder; +import io.restassured.response.Response; +import io.vertx.core.DeploymentOptions; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpHeaders; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.folio.edge.core.utils.ApiKeyUtils; +import org.folio.edge.core.utils.test.TestUtils; +import org.folio.edge.patron.model.Account; +import org.folio.edge.patron.model.Hold; +import org.folio.edge.patron.model.Loan; +import org.folio.edge.patron.model.error.ErrorMessage; +import org.folio.edge.patron.utils.JwtTokenUtil; +import org.folio.edge.patron.utils.PatronMockOkapi; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + @RunWith(VertxUnitRunner.class) public class MainVerticleTest { @@ -86,6 +89,7 @@ public class MainVerticleTest { private static Vertx vertx; private static PatronMockOkapi mockOkapi; + private static JwtTokenUtil jwtTokenUtil; @BeforeClass public static void setUpOnce(TestContext context) throws Exception { @@ -96,6 +100,7 @@ public static void setUpOnce(TestContext context) throws Exception { knownTenants.add(ApiKeyUtils.parseApiKey(apiKey).tenantId); vertx = Vertx.vertx(); + jwtTokenUtil = new JwtTokenUtil(); System.setProperty(SYS_PORT, String.valueOf(serverPort)); System.setProperty(SYS_OKAPI_URL, "http://localhost:" + okapiPort); @@ -103,6 +108,7 @@ public static void setUpOnce(TestContext context) throws Exception { System.setProperty(SYS_LOG_LEVEL, "DEBUG"); System.setProperty(SYS_RESPONSE_COMPRESSION, "true"); System.setProperty(SYS_REQUEST_TIMEOUT_MS, String.valueOf(requestTimeoutMs)); + System.setProperty(KEYCLOAK_URL, "http://localhost:" + okapiPort); mockOkapi = spy(new PatronMockOkapi(okapiPort, knownTenants)); mockOkapi.start() @@ -254,6 +260,126 @@ public void testGetAccountPatronNotFound(TestContext context) throws Exception { assertEquals(expectedStatusCode, msg.httpStatusCode); } + @Test + public void testSecureGetAccount(TestContext context) { + final String expected = PatronMockOkapi.getAccountJson(patronId, false, false, false); + RestAssured + .given() + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, true)) + .header(X_OKAPI_TENANT, "diku") + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .body(is(expected)); + } + + @Test + public void testSecureGetAccountInvalidToken(TestContext context) throws Exception { + var resp = RestAssured + .given() + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, true) + "001") + .header(X_OKAPI_TENANT, "diku") + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var error = ErrorMessage.fromJson(resp.body().asString()); + assertEquals("Failed to validate access token", error.message); + assertEquals(400, error.httpStatusCode); + } + + @Test + public void testSecureGetAccountMissingTenant(TestContext context) throws Exception { + var resp = RestAssured + .given() + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, true) + "001") + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var error = ErrorMessage.fromJson(resp.body().asString()); + assertEquals("Missing tenant id", error.message); + assertEquals(400, error.httpStatusCode); + } + + @Test + public void testSecureGetAccountMissingToken(TestContext context) throws Exception { + var resp = RestAssured + .given() + .header(X_OKAPI_TENANT, "diku") + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var error = ErrorMessage.fromJson(resp.body().asString()); + assertEquals("Missing access token", error.message); + assertEquals(400, error.httpStatusCode); + } + + @Test + public void testSecureGetAccountMissingKeycloakPublicKey(TestContext context) throws Exception { + var resp = RestAssured + .given() + .header(X_OKAPI_TENANT, "test") + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, true)) + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var error = ErrorMessage.fromJson(resp.body().asString()); + assertEquals("Failed to validate access token", error.message); + assertEquals(400, error.httpStatusCode); + } + + @Test + public void testSecureGetAccountWithMissingClaims(TestContext context) throws Exception { + var resp = RestAssured + .given() + .header(X_OKAPI_TENANT, "diku") + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken()) + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(400) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var error = ErrorMessage.fromJson(resp.body().asString()); + assertEquals("Token doesn't contain required claims", error.message); + assertEquals(400, error.httpStatusCode); + } + + @Test + public void testSecureGetAccountWithPatronNotVip(TestContext context) throws Exception { + var resp = RestAssured + .given() + .header(X_OKAPI_TENANT, "diku") + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, false)) + .get(String.format("/patron/account?apikey=%s", apiKey)) + .then() + .statusCode(401) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + var error = ErrorMessage.fromJson(resp.body().asString()); + assertEquals("Access Denied", error.message); + assertEquals(401, error.httpStatusCode); + } + @Test public void testGetPatronRegistrationStatusWithoutEmail(TestContext context) { @@ -780,6 +906,28 @@ public void testPlaceInstanceHoldSuccess(TestContext context) throws Exception { validateHolds(expected, actual); } + @Test + public void testSecurePlaceInstanceHoldSuccess(TestContext context) throws Exception { + Hold hold = PatronMockOkapi.getHold(instanceId); + + final Response resp = RestAssured + .with() + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(patronId, true)) + .header(X_OKAPI_TENANT, "diku") + .body(hold.toJson()) + .contentType(APPLICATION_JSON) + .post( + String.format("/patron/account/instance/%s/hold?apikey=%s", instanceId, apiKey)) + .then() + .statusCode(201) + .extract() + .response(); + + Hold expected = Hold.fromJson(PatronMockOkapi.getPlacedHoldJson(hold)); + Hold actual = Hold.fromJson(resp.body().asString()); + validateHolds(expected, actual); + } + @Test public void testPlaceInstanceHoldPatronNotFound(TestContext context) throws Exception { logger.info("=== Test place instance hold w/ patron not found ==="); @@ -1277,6 +1425,28 @@ public void testAllowedServicePointsSuccess(TestContext context) throws Exceptio JsonObject actual = new JsonObject(resp.body().asString()); assertEquals(expected, actual); } + + @Test + public void testSecureAllowedServicePointsSuccess(TestContext context) { + + final Response resp = RestAssured + .with() + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(patronId, true)) + .header(X_OKAPI_TENANT, "diku") + .get(String.format("/patron/account/instance/%s/allowed-service-points?apikey=%s", + instanceId, apiKey)) + .then() + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + JsonObject expected = new JsonObject(readMockFile( + "/allowed_sp_mod_patron_expected_response.json")); + JsonObject actual = new JsonObject(resp.body().asString()); + assertEquals(expected, actual); + } + @Test public void testAllowedServicePointsForItemError(TestContext context) throws Exception { logger.info("=== Test validation error during allowed service points request ==="); @@ -1346,6 +1516,35 @@ public void testCancelHoldSuccess(TestContext context) throws Exception { assertEquals(expected.canceledByUserId, actual.canceledByUserId); } + @Test + public void testSecureCancelHoldSuccess(TestContext context) throws Exception { + String cancedHoldJson = PatronMockOkapi.getHoldCancellation(holdCancellationHoldId, patronId); + + final Response resp = RestAssured + .with() + .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(patronId, true)) + .header(X_OKAPI_TENANT, "diku") + .contentType(APPLICATION_JSON) + .body(cancedHoldJson) + .post( + String.format("/patron/account/hold/%s/cancel?apikey=%s", holdCancellationHoldId, apiKey)) + .then() + .statusCode(200) + .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .extract() + .response(); + + Hold expected = Hold.fromJson(PatronMockOkapi.getRemovedHoldJson(holdCancellationHoldId)); + Hold actual = Hold.fromJson(resp.body().asString()); + + assertEquals(expected, actual); + assertEquals(holdCancellationHoldId, expected.requestId); + assertEquals(PatronMockOkapi.holdCancellationReasonId, actual.cancellationReasonId); + assertEquals(Hold.Status.CLOSED_CANCELED, actual.status); + assertEquals(0, actual.queuePosition); + assertEquals(expected.canceledByUserId, actual.canceledByUserId); + } + @Test public void testCancelHoldSuccessWithNonUUIDCanceledById(TestContext context) throws Exception { logger.info("=== Test cancel hold success ==="); diff --git a/src/test/java/org/folio/edge/patron/utils/JwtTokenUtil.java b/src/test/java/org/folio/edge/patron/utils/JwtTokenUtil.java new file mode 100644 index 0000000..67f248f --- /dev/null +++ b/src/test/java/org/folio/edge/patron/utils/JwtTokenUtil.java @@ -0,0 +1,31 @@ +package org.folio.edge.patron.utils; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Jwk; +import io.jsonwebtoken.security.Jwks; + +public class JwtTokenUtil { + private Jwk key; + + public JwtTokenUtil() { + key = Jwks.parser().build().parse(this.getClass().getResourceAsStream("/test_jwk.json")); + } + + public String generateToken(String externalSystemId, boolean vip) { + return Jwts.builder() + .header().keyId(key.getId()).and() + .subject("test") + .signWith(key.toKey()) + .claim("externalSystemId", externalSystemId) + .claim("vip", vip) + .compact(); + } + + public String generateToken() { + return Jwts.builder() + .header().keyId(key.getId()).and() + .subject("test") + .signWith(key.toKey()) + .compact(); + } +} diff --git a/src/test/java/org/folio/edge/patron/utils/PatronMockOkapi.java b/src/test/java/org/folio/edge/patron/utils/PatronMockOkapi.java index 33f1bbe..5f29689 100644 --- a/src/test/java/org/folio/edge/patron/utils/PatronMockOkapi.java +++ b/src/test/java/org/folio/edge/patron/utils/PatronMockOkapi.java @@ -1,5 +1,23 @@ package org.folio.edge.patron.utils; +import static java.util.Collections.singletonList; +import static org.folio.edge.core.Constants.APPLICATION_JSON; +import static org.folio.edge.core.Constants.DAY_IN_MILLIS; +import static org.folio.edge.core.Constants.TEXT_PLAIN; +import static org.folio.edge.core.Constants.X_OKAPI_TOKEN; +import static org.folio.edge.patron.Constants.PARAM_EMAIL_ID; +import static org.folio.edge.patron.Constants.PARAM_HOLD_ID; +import static org.folio.edge.patron.Constants.PARAM_INCLUDE_CHARGES; +import static org.folio.edge.patron.Constants.PARAM_INCLUDE_HOLDS; +import static org.folio.edge.patron.Constants.PARAM_INCLUDE_LOANS; +import static org.folio.edge.patron.Constants.PARAM_INSTANCE_ID; +import static org.folio.edge.patron.Constants.PARAM_ITEM_ID; +import static org.folio.edge.patron.Constants.PARAM_LIMIT; +import static org.folio.edge.patron.Constants.PARAM_OFFSET; +import static org.folio.edge.patron.Constants.PARAM_PATRON_ID; +import static org.folio.edge.patron.Constants.PARAM_REQUEST_ID; +import static org.folio.edge.patron.Constants.PARAM_SORT_BY; + import com.fasterxml.jackson.core.JsonProcessingException; import io.vertx.core.http.HttpHeaders; import io.vertx.core.http.HttpMethod; @@ -7,6 +25,16 @@ import io.vertx.core.json.JsonObject; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Currency; +import java.util.Date; +import java.util.List; +import java.util.UUID; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -20,35 +48,6 @@ import org.folio.edge.patron.model.Loan; import org.folio.edge.patron.model.Money; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Currency; -import java.util.Date; -import java.util.List; -import java.util.UUID; - -import static java.util.Collections.singletonList; -import static org.folio.edge.core.Constants.APPLICATION_JSON; -import static org.folio.edge.core.Constants.DAY_IN_MILLIS; -import static org.folio.edge.core.Constants.TEXT_PLAIN; -import static org.folio.edge.core.Constants.X_OKAPI_TOKEN; -import static org.folio.edge.patron.Constants.PARAM_EMAIL_ID; -import static org.folio.edge.patron.Constants.PARAM_HOLD_ID; -import static org.folio.edge.patron.Constants.PARAM_INCLUDE_CHARGES; -import static org.folio.edge.patron.Constants.PARAM_INCLUDE_HOLDS; -import static org.folio.edge.patron.Constants.PARAM_INCLUDE_LOANS; -import static org.folio.edge.patron.Constants.PARAM_INSTANCE_ID; -import static org.folio.edge.patron.Constants.PARAM_ITEM_ID; -import static org.folio.edge.patron.Constants.PARAM_LIMIT; -import static org.folio.edge.patron.Constants.PARAM_OFFSET; -import static org.folio.edge.patron.Constants.PARAM_PATRON_ID; -import static org.folio.edge.patron.Constants.PARAM_REQUEST_ID; -import static org.folio.edge.patron.Constants.PARAM_SORT_BY; - public class PatronMockOkapi extends MockOkapi { private static final Logger logger = LogManager.getLogger(PatronMockOkapi.class); @@ -168,9 +167,19 @@ public Router defineRoutes() { router.route(HttpMethod.GET, "/patron/registration-status/:emailId") .handler(this::getRegistrationStatusHandler); + router.route(HttpMethod.GET, "/realms/diku/protocol/openid-connect/certs") + .handler(this::getKeycloakPublicKeysHandler); + return router; } + public void getKeycloakPublicKeysHandler(RoutingContext ctx) { + ctx.response() + .setStatusCode(200) + .putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON) + .end(readMockFile("/keycloak_certs_response.json")); + } + public void getPatronHandler(RoutingContext ctx) { String query = ctx.request().getParam(PARAM_QUERY); String token = ctx.request().getHeader(X_OKAPI_TOKEN); diff --git a/src/test/resources/keycloak_certs_response.json b/src/test/resources/keycloak_certs_response.json new file mode 100644 index 0000000..206692b --- /dev/null +++ b/src/test/resources/keycloak_certs_response.json @@ -0,0 +1,16 @@ +{ + "keys": [ + { + "kty": "RSA", + "use": "sig", + "key_ops": [ + "sign", + "verify" + ], + "alg": "RS256", + "kid": "5f1fdf63-2a16-47c8-b882-66d17f1f0d39", + "n": "wl-JE22eQrjVGiq1kR2KouA1ne6mddotGqG5n89ejnw3FPUWRk7TlWG87O0mXsjmvnjnnJTKdItaFJlHwoQy4bMWPGDnwDc4kGi2iQ52UgpUq0M4Ss3U_RDsCoNVsclmJMmfzmS1SK9NOExdWmaWXj9wuItzwy4vZ-skg-R_ldd-_go6kP_og5Xn9_DRQI1Zv68rTxm-2usjinoJHE6aqta6_JZURQU1PF34SkUqFLF0vBgOUwTF_94-q7z2BDIJgYnk3yudVQOEDXLo7mspdOcNiavyH4w3gKqnT02FSaszeWlZ1hmGPt0Lz1tMPAoNjZx8esE1b8ze7aKeMGi5NQ", + "e": "AQAB" + } + ] +} diff --git a/src/test/resources/test_jwk.json b/src/test/resources/test_jwk.json new file mode 100644 index 0000000..80f33a0 --- /dev/null +++ b/src/test/resources/test_jwk.json @@ -0,0 +1,18 @@ +{ + "kty": "RSA", + "use": "sig", + "key_ops": [ + "sign", + "verify" + ], + "alg": "RS256", + "kid": "5f1fdf63-2a16-47c8-b882-66d17f1f0d39", + "d": "Bg8qW8OwCk5uvjkUneh42Oj6Yuj8oXHitBDUk4nIXdK7eNjHD_wHFoIMfKpL5uqGXuuv9K6ivQ1XCotYtFSgrW6Cw_EVaGWQStgo0I7120rdJtWanKJcAGOVqCI9-qPXnk_2vl1fVVu5oYan5liKWXfK0MXwHuaCrc-jvMTTwVz_acDojcJbPoFK03D2BUxMBH6Gon6MI09NESk7Ni09BHgrlR2IHGUv_Q2C14uo8RH86h_7kjyW5x0lInTuimKqtvat0DXK9Nt2Rs9y1Gcbtlxu9yplv1E0XrodCwvoJwnK6gIVRvAlFUT4f8Bw1q9Lkp-ARp6BG9a3F2Q5zbTVwQ", + "n": "wl-JE22eQrjVGiq1kR2KouA1ne6mddotGqG5n89ejnw3FPUWRk7TlWG87O0mXsjmvnjnnJTKdItaFJlHwoQy4bMWPGDnwDc4kGi2iQ52UgpUq0M4Ss3U_RDsCoNVsclmJMmfzmS1SK9NOExdWmaWXj9wuItzwy4vZ-skg-R_ldd-_go6kP_og5Xn9_DRQI1Zv68rTxm-2usjinoJHE6aqta6_JZURQU1PF34SkUqFLF0vBgOUwTF_94-q7z2BDIJgYnk3yudVQOEDXLo7mspdOcNiavyH4w3gKqnT02FSaszeWlZ1hmGPt0Lz1tMPAoNjZx8esE1b8ze7aKeMGi5NQ", + "e": "AQAB", + "p": "yQGC_gFCE3DojmUdHkJ9sLiVNNQ3wM7Y6oudme-jvcmQY2kKhJVc9QuUYn0xStzIT4o11NzQpNizUOdjJja3ZRDkx8ay7j4hc3NaZVZb9-ukLAYbltoDxgo4xFIoQ5Y5rj0nCid_3FnY1tqbIlEJG4Fd178RPWOZrW5nX8h3mSU", + "q": "9413iOoZLg65trWQOgotEU71e41nvWg9yCmNkfVZ5A8c_BE2s0A5Dmn9WYWXIaoSv67TmguMU9BTHu_S24qqPv0QRzZX9U33GXbrUt5dHo5haiXKIir5Yg8BxETTqFVYdxnM3SZW_41LqQYs-wmv1x4HZXBH37IAdAVk8jqAStE", + "dp": "DGy2fyLuxareBSdE5IDxqgHO30Qa6iUfDWhx5nkEow-ZiDuO9eERrOf5VRkt-dWp4BjH-Q9pKjdm5iJXY55QOcQQkDS9DLL0eGFx_f-XkbyUGlCKVgnF3_Dzz1bQvFTF3fpTtnH4mlNHbwh2PGnL6VJWzaY215eXgTvo0effVK0", + "dq": "EgZ3Ab0qADSKSUeHLPK4vV3megyd1SjV9tEvwcT_up9vGNuYBA1VGjuVewNDMexUWSi9t6XHngK5SrNjwyChrNx4ZvcKCI6Yw33pPKt8VFFBvpzpzvsaFY3KLyRj1QoB2wpB5Ih6JTmAnNoaRF08NIm3OCeo1Bz983TBGPIxjUE", + "qi": "PmUhgOYkxBPWgNCIPzbls1tW0SHjRi1hpljZX-VBKd_UYqJtJldEijt6pFRXZiPOKEt0j1bACdQoF3owUGPXK8Z7IUPqN3FR0cAQ_zIV74gJMDku_Sqtw5BccP2nNzQ_CNWsSHX2qn9lSNeTa1HtCFSqZ0ypXk2ajKQjuiugfPY" +}