package org.folio.okapi.auth; import io.vertx.core.MultiMap; import io.vertx.core.http.HttpMethod; import io.vertx.core.json.DecodeException; import io.vertx.core.json.Json; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.RoutingContext; import java.util.Base64; import java.util.HashMap; import org.apache.logging.log4j.Logger; import org.folio.okapi.common.HttpResponse; import org.folio.okapi.common.OkapiLogger; import org.folio.okapi.common.XOkapiHeaders; /** * A dummy auth module. Provides a minimal authentication mechanism. * Mostly for testing Okapi itself. * Does generate tokens for module permissions, but otherwise does not filter * permissions for anything, but does return X-Okapi-Permissions-Desired in * X-Okapi-Permissions, as if all desired permissions were granted. */ @java.lang.SuppressWarnings({"squid:S1192"}) class Auth { private final Logger logger = OkapiLogger.get(); /** * Calculate a token from tenant and username. Fakes a JWT token, almost * but not quite completely unlike the one that a real auth module would create. * The important point is that it is a JWT, and that it has a tenant in it, * so Okapi can recover it in case it does not see a X-Okapi-Tenant header, * as happens in some unit tests. * * @param tenant tenant string * @param user user string * @return the token */ private String token(String tenant, String user) { // Create a dummy JWT token with the correct tenant JsonObject payload = new JsonObject() .put("sub", user) .put("tenant", tenant); String encodedpl = payload.encode(); logger.debug("test-auth: payload: {}", encodedpl); byte[] bytes = encodedpl.getBytes(); byte[] pl64bytes = Base64.getEncoder().encode(bytes); String pl64 = new String(pl64bytes); String token = "dummyJwt." + pl64 + ".sig"; logger.debug("test-auth: token: {}", token); return token; } public void login(RoutingContext ctx) { final String json = ctx.getBodyAsString(); if (json.length() == 0) { logger.debug("test-auth: accept OK in login"); HttpResponse.responseText(ctx, 202).end("Auth accept in /authn/login"); return; } LoginParameters p; try { p = Json.decodeValue(json, LoginParameters.class); } catch (DecodeException ex) { HttpResponse.responseText(ctx, 400).end("Error in decoding parameters: " + ex); return; } // Simple password validation: "peter" has a password "peter-password", etc. String u = p.getUsername(); String correctpw = u + "-password"; if (!p.getPassword().equals(correctpw)) { logger.warn("test-auth: Bad passwd for '{}'. Got '{}' expected '{}", u, p.getPassword(), correctpw); HttpResponse.responseText(ctx, 401).end("Wrong username or password"); return; } String tok; tok = token(p.getTenant(), p.getUsername()); logger.info("test-auth: Ok login for {}: {}", u, tok); HttpResponse.responseJson(ctx, 200).putHeader(XOkapiHeaders.TOKEN, tok).end(json); } /** * Fake some module permissions. * Generates silly tokens with the module name as the tenant, and a list * of permissions as the user. These are still valid tokens, although it is * not possible to extract the user or tenant from them. */ private String moduleTokens(RoutingContext ctx) { String modPermJson = ctx.request().getHeader(XOkapiHeaders.MODULE_PERMISSIONS); logger.debug("test-auth: moduleTokens: trying to decode '{}'", modPermJson); HashMap<String, String> tokens = new HashMap<>(); if (modPermJson != null && !modPermJson.isEmpty()) { JsonObject jo = new JsonObject(modPermJson); StringBuilder permstr = new StringBuilder(); for (String mod : jo.fieldNames()) { JsonArray ja = jo.getJsonArray(mod); for (int i = 0; i < ja.size(); i++) { String p = ja.getString(i); if (permstr.length() > 0) { permstr.append(","); } permstr.append(p); } tokens.put(mod, token(mod, permstr.toString())); } } if (!tokens.isEmpty()) { // return also a 'clean' token tokens.put("_", ctx.request().getHeader(XOkapiHeaders.TOKEN)); } String alltokens = Json.encode(tokens); logger.debug("test-auth: module tokens for {}: {}", modPermJson, alltokens); return alltokens; } public void filter(RoutingContext ctx) { String phase = ctx.request().headers().get(XOkapiHeaders.FILTER); logger.debug("test-auth filter {}: '{}'", XOkapiHeaders.FILTER, phase); if (phase == null || phase.startsWith("auth")) { check(ctx); return; } ctx.response().putHeader("X-Auth-Filter-Phase", phase); // Hack to test return codes on various filter phases phase = phase.split(" ")[0]; String phaseHeader = ctx.request().headers().get("X-Filter-" + phase); logger.debug("filter: 'X-Filter-{}': {}", phase, phaseHeader); if (phaseHeader != null) { ctx.response().setStatusCode(Integer.parseInt(phaseHeader)); } // Hack to test pre/post filter returns error if (ctx.request().headers().contains("X-filter-" + phase + "-error")) { ctx.response().setStatusCode(500); } // Hack to test pre/post filter can see request headers if (ctx.request().headers().contains("X-request-" + phase + "-error") && ctx.request().headers().contains(XOkapiHeaders.REQUEST_IP) && ctx.request().headers().contains(XOkapiHeaders.REQUEST_TIMESTAMP) && ctx.request().headers().contains(XOkapiHeaders.REQUEST_METHOD)) { ctx.response().setStatusCode(500); } echo(ctx); } public void check(RoutingContext ctx) { MultiMap headers = ctx.request().headers(); final String req = headers.get(XOkapiHeaders.PERMISSIONS_REQUIRED); String tenant = headers.get(XOkapiHeaders.TENANT); if (tenant == null) { tenant = "supertenant"; } String userId = "?"; String tok = headers.get(XOkapiHeaders.TOKEN); if (tok == null || tok.isEmpty()) { // Only make a token if no permissions are required if (req != null && !req.isEmpty()) { HttpResponse.responseError(ctx, 401, "Permissions required: " + req); return; } tok = token(tenant, "-"); // create a dummy token without username // We call /_/tenant and /_/tenantPermissions in our tests without a token. // In real life, this is more complex, mod-authtoken creates a non- // login token, possibly with modulePermissions, and then checks that // against the permissions required for the tenant interface... } else { logger.debug("test-auth: check starting with tok {} and tenant {}", tok, tenant); String[] splitTok = tok.split("\\."); if (splitTok.length != 3) { logger.warn("test-auth: Bad JWT, can not split in three parts. '{}", tok); HttpResponse.responseError(ctx, 400, "Auth.check: Bad JWT"); return; } if (!"dummyJwt".equals(splitTok[0])) { logger.warn("test-auth: Bad dummy JWT, starts with '{}', not 'dummyJwt'", splitTok[0]); HttpResponse.responseError(ctx, 400, "Auth.check needs a dummyJwt"); return; } String payload = splitTok[1]; try { String decodedJson = new String(Base64.getDecoder().decode(payload)); logger.debug("test-auth: check payload: {}", decodedJson); JsonObject jtok = new JsonObject(decodedJson); userId = jtok.getString("sub", ""); } catch (IllegalArgumentException e) { HttpResponse.responseError(ctx, 400, "Bad Json payload " + payload); return; } final String ovTok = headers.get(XOkapiHeaders.ADDITIONAL_TOKEN); logger.info("ovTok={}", ovTok); if (ovTok != null && !"dummyJwt".equals(ovTok)) { HttpResponse.responseError(ctx, 400, "Bad additonal token: " + ovTok); return; } } // Fail a call to /_/tenant that requires permissions (Okapi-538) if ("/_/tenant".equals(ctx.request().path()) && req != null) { logger.warn("test-auth: Rejecting request to /_/tenant because of {}: {}", XOkapiHeaders.PERMISSIONS_REQUIRED, req); HttpResponse.responseError(ctx, 403, "/_/tenant can not require permissions"); return; } // Fake some desired permissions String des = headers.get(XOkapiHeaders.PERMISSIONS_DESIRED); if (des != null) { ctx.response().headers().add(XOkapiHeaders.PERMISSIONS, des); } if (req != null) { ctx.response().headers().add("X-Auth-Permissions-Required", req); } if (des != null) { ctx.response().headers().add("X-Auth-Permissions-Desired", des); } // Fake some module tokens String modTok = moduleTokens(ctx); ctx.response().headers() .add(XOkapiHeaders.TOKEN, tok) .add(XOkapiHeaders.MODULE_TOKENS, modTok) .add(XOkapiHeaders.USER_ID, userId); HttpResponse.responseText(ctx, 202); // Abusing 202 to say filter OK if (ctx.request().method() == HttpMethod.HEAD) { ctx.response().headers().remove("Content-Length"); ctx.response().setChunked(true); logger.debug("test-auth: Head request"); //ctx.response().end("ACCEPTED"); // Dirty trick?? ctx.response().write("Accpted"); logger.debug("test-auth: Done with the HEAD response"); } else { echo(ctx); } } private void echo(RoutingContext ctx) { logger.debug("test-auth: echo"); ctx.response().setChunked(true); String ctype = ctx.request().headers().get("Content-Type"); if (ctype != null && !ctype.isEmpty()) { ctx.response().headers().set("Content-type", ctype); } ctx.request().handler(x -> { logger.debug("test-auth: echoing {}", x); ctx.response().write(x); }); ctx.request().endHandler(x -> { logger.debug("test-auth: endhandler"); ctx.response().end(); logger.debug("test-auth: endhandler ended the response"); }); } /** * Accept a request. Gets called with anything else than a POST to "/authn/login". * These need to be accepted, so we can do a pre-filter before * the proper POST. * * @param ctx Routing context */ public void accept(RoutingContext ctx) { logger.info("test-auth: Auth accept OK"); HttpResponse.responseText(ctx, 202); echo(ctx); } }