diff --git a/Overview.md b/Overview.md new file mode 100755 index 0000000..1a067a9 --- /dev/null +++ b/Overview.md @@ -0,0 +1,66 @@ +# EGA Data API v3 -- Overview + +The EGA Data API is a portable set of microservices providing a REST API to access to EGA data. There are currently several clients available for this API, including a command line download client and a FUSE layer for direct random access to archived files. + +The API is written in Java 1.8 using the Spring Framework and Spring Boot 1.4. As a prerequisite two Spring Servers are expected to be running at all times: +* (Netflix) Eureka Server +* (Netflix) Config Server + +All sercives register themselves with the Eureka instance, using the Spring application name `{app.name}` specified in `'bootstrap.configuration'`:`"spring.application.name={app.name}"`. Contact between microservices then utilises `{app.name}` instead of the absolute URL. Eureka automatically resolves the application name to a URL; if multiple instances with the same application name are present, Eureka automatically performs Ribbon load balancing between the instances. This greatly simplifies deployment and allows for easy and dynamic scaling to meet demand. + +The Configuration server serves all `'application.configuration'` files to the respective microservices. The location (URL) of the config server must be specified in the `'bootstrap.configuration'`:`"spring.cloud.config.uri="` file of each service. This entry requires an absolute URL to the configration server. This is necessary because the configuration file must be loaded before the application startup. A config file named `'{app.name}.configuration'` must be in the config directory referenced by the configuration server before a service with `"spring.application.name={app.name}"` can be started. + +Without these two services the remaining microservices will not function properly. + +There are two microservices interfacing with a database: +* FILEDATABASE +* PERMISSIONSDATABASE [used only at Central EGA] + +The PERMISSIONSDATABASE service reads (read-only) permissions-related information (user-dataset associations; etc.) while the FILEDATABASE service serves archive-related information (file-dataset associations; file paths; etc.) and keeps some logs (read/write). + +Two microservices provide publicly accessible Edge services: +* CENTRAL [used only at Central EGA] +* DATAEDGE + +DATAEDGE provides access to the data. It provides access to archived files directly; it streams downloas. This is the primary back end for the FUSE layer. All DATAEDGE endpoints require a valid EGA (ELIXIR) OAuth2 Bearer Token. Security for CENTRAL is different because it is meant as a service-facing API. CENTRAL serves user-dataset associations, and general dataset-file information. + +One microservice serves as front door to all requests. It is a reverse proxy/filter and performs functions such as rate limitations, permissions-injection for ELIXIR access tokens. It also forwards all requests to an available back end service, based on availability provided by EUREKA, thus tying all services behind a single URL and load-balancing multiple back end services. + +At EGA Central these services are deployed behind an SSL terminating load balancer; therefore https is not initially implemented with these services. + +The CPU heavy cryptographic work is performed by the RES service, supported by a Key provider service and an H2 database (to enable this service to be properly load balanced). The key provider service produces the archive decryption key for a specified file, and is only used from within RES. The H2 database stores MD5 values of data transfers, to enable verification of completed downloads. +* KEY +* H2 DB +* RES + +### Summary +* EUREKA + * Requires: CONFIG + * Required By: DATAEDGE, CENTRAL PERMISSIONSDATABASE, FILEDATABASE, RES, KEY, ZUUL +* CONFIG + * Requires: + * Required By: DATAEDGE, CENTRAL PERMISSIONSDATABASE, FILEDATABASE, RES, KEY +* H2 + * Requires: + * Required By: RES, DATAEDGE +* KEY + * Requires: EUREKA, CONFIG + * Required By: RES +* PERMISSIONSDATABASE + * Requires: EUREKA, CONFIG + * Required By: CENTRAL +* FILEDATABASE + * Requires: EUREKA, CONFIG + * Required By: DATAEDGE +* DATAEDGE + * Requires: EUREKA, CONFIG, FILEDATABASE + * Required By: ZUUL +* CENTRAL + * Requires: EUREKA, CONFIG, PERMISSIONSDATABASE + * Required By: ZUUL +* RES + * Requires: EUREKA, CONFIG, KEY, H2 + * Required By: FILEDATABASE +* ZUUL + * Requires: EUREKA, CONFIG + * Required By: diff --git a/README.md b/README.md new file mode 100755 index 0000000..c5e153c --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Zuul Server + +Run this app as a normal Spring Boot app. If you run from this project +it will be on port 8765 (per the `application.yml`). +Also run : [eureka](https://github.com/EGA-archive/ega-eureka-service) + +Zuul has several functions: +* Integration with ELIXIR. The Zuul filter detects ELIXIR tokens, and injects EGA permissions in the REST call as a signed "X-Permissions" header. +* Traffic Shaping to protect the underlying resources +* Load balancing: automatically (via EUREKA) detect and add/remove available back end API services, and load balance between them. Also perform atomatic retries of REST calls. +* Integration: Make several back end microservices availabe on a single URL and Port diff --git a/docker/Dockerfile_Deploy b/docker/Dockerfile_Deploy new file mode 100755 index 0000000..90fe2f5 --- /dev/null +++ b/docker/Dockerfile_Deploy @@ -0,0 +1,23 @@ +# Use phusion/baseimage as base image. +FROM phusion/baseimage:latest + +# Use baseimage-docker's init system. +CMD ["/sbin/my_init"] + +# custon build instructions here... +# Java: OpenJDK8 +RUN apt-get update +RUN apt-get install -y software-properties-common python-software-properties +RUN add-apt-repository ppa:openjdk-r/ppa +RUN apt-get update +RUN apt-get -y install openjdk-8-jdk +ADD zuul-server-1.0.0.BUILD-SNAPSHOT.jar /zuul-server-1.0.0.BUILD-SNAPSHOT.jar +RUN mkdir /etc/service/ega_v3_zuul +ADD zuuld.sh /etc/service/ega_v3_zuul/run +RUN chmod +x /etc/service/ega_v3_zuul/run + +# Clean up APT when done. +RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# Port +EXPOSE 8051 diff --git a/docker/Dockerfile_EGABuild b/docker/Dockerfile_EGABuild new file mode 100755 index 0000000..8190400 --- /dev/null +++ b/docker/Dockerfile_EGABuild @@ -0,0 +1,30 @@ +# Build Ubuntu Base Image +FROM ubuntu:latest + +# For now... +MAINTAINER Alexander Senf + +# ROOT to set up the image +USER root + +# Add a user and group first to make sure their IDs get assigned consistently, regardless of whatever dependencies get added +#RUN groupadd -r ega && useradd -r -g ega ega + +# Dirs in Docker FS +#RUN mkdir /docker-entrypoint-initdb.d + +# Suppress unnecesary warning messages +RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections + +# Enable addition of Repositories in Ubuntu +RUN apt-get update -y +RUN apt-get install -y software-properties-common python-software-properties + +# Update Image, and Install Java 8 & Monit +RUN add-apt-repository ppa:openjdk-r/ppa +RUN apt-get -y update +RUN apt-get -y install openjdk-8-jdk +RUN apt-get -y install git +RUN apt-get -y install maven +RUN apt -y upgrade + diff --git a/docker/build.sh b/docker/build.sh new file mode 100755 index 0000000..b5e3418 --- /dev/null +++ b/docker/build.sh @@ -0,0 +1,6 @@ +#!/bin/bash +git clone https://github.com/EbiEga/Ega_ZUUL_Server.git +mvn -f /Ega_ZUUL_Server/pom.xml install +mv /Ega_ZUUL_Server/target/zuul-server-1.0.0.BUILD-SNAPSHOT.jar /EGA_build +mv /Ega_ZUUL_Server/docker/eurekad.sh /EGA_build +mv /Ega_ZUUL_Server/docker/Dockerfile_Deploy /EGA_build diff --git a/docker/runfromimage.sh b/docker/runfromimage.sh new file mode 100755 index 0000000..ffa23bc --- /dev/null +++ b/docker/runfromimage.sh @@ -0,0 +1,2 @@ +#!/bin/bash +sudo docker run -d -p 8051:8051 alexandersenf/ega_zuul diff --git a/docker/runfromsource.sh b/docker/runfromsource.sh new file mode 100755 index 0000000..b3f1e3d --- /dev/null +++ b/docker/runfromsource.sh @@ -0,0 +1,8 @@ +#!/bin/bash +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +sudo docker run --rm --name build -v $DIR:/EGA_build -it alexandersenf/ega_zuul sh -c 'exec /EGA_build/build.sh' +sudo docker build -t ega_zuul -f Dockerfile_Deploy . +sudo rm zuul-server-1.0.0.BUILD-SNAPSHOT.jar +sudo rm Dockerfile_Deploy +sudo rm zuuld.sh +sudo docker run -d -p 8051:8051 ega_zuul diff --git a/docker/zuuld.sh b/docker/zuuld.sh new file mode 100755 index 0000000..b5a09b4 --- /dev/null +++ b/docker/zuuld.sh @@ -0,0 +1,12 @@ +#!/bin/bash +SERVICE_NAME=ZuulService +PATH_TO_JAR=/zuul-server-1.0.0.BUILD-SNAPSHOT.jar +PROCESSCNT=$(ps x | grep -v grep | grep -c "zuul-server-1.0.0.BUILD-SNAPSHOT.jar") +#PID=$(ps aux | grep "zuul-server-1.0.0.BUILD-SNAPSHOT.jar" | grep -v grep | awk '{print $2}') +if [ $PROCESSCNT == 0 ]; then + echo "Starting $SERVICE_NAME ..." + nohup java -jar $PATH_TO_JAR 2>> /dev/null >> /dev/null & + echo "$SERVICE_NAME started ..." +#else +# echo "$SERVICE_NAME is already running ..." +fi diff --git a/pom.xml b/pom.xml new file mode 100755 index 0000000..7651672 --- /dev/null +++ b/pom.xml @@ -0,0 +1,231 @@ + + + 4.0.0 + + org.demo + zuul-server + jar + Spring Cloud Netflix Zuul Server + http://projects.spring.io/spring-cloud/ + 1.0.0.BUILD-SNAPSHOT + + + org.springframework.boot + spring-boot-starter-parent + 1.5.10.BUILD-SNAPSHOT + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + maven-deploy-plugin + + true + + + + + + + + + + + org.springframework.cloud + spring-cloud-dependencies + Camden.SR7 + pom + import + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.cloud + spring-cloud-starter-eureka + + + org.springframework.cloud + spring-cloud-starter-config + + + + + com.squareup.okhttp3 + okhttp + 3.4.2 + + + com.squareup.moshi + moshi + 1.3.1 + + + + com.github + bucket4j + 1.3.0 + + + + org.projectlombok + lombok + 1.16.10 + jar + + + com.marcosbarbero.cloud + spring-cloud-zuul-ratelimit + 1.0.7.RELEASE + + + + org.springframework.security + spring-security-jwt + 1.0.9.RELEASE + jar + + + org.springframework.security.oauth + spring-security-oauth2 + 2.0.14.RELEASE + jar + + + + + + spring-snapshots + Spring Snapshots + http://repo.spring.io/libs-snapshot-local + + true + + + + spring-milestones + Spring Milestones + http://repo.spring.io/libs-miletone-local + + false + + + + spring-releases + Spring Releases + http://repo.spring.io/libs-release-local + + false + + + + + jcenter + http://jcenter.bintray.com + + + + + + spring-snapshots + Spring Snapshots + http://repo.spring.io/libs-snapshot-local + + true + + + + spring-milestones + Spring Milestones + http://repo.spring.io/libs-milestone-local + + false + + + + + diff --git a/src/main/java/zuulserver/JwtAlgorithms.java b/src/main/java/zuulserver/JwtAlgorithms.java new file mode 100755 index 0000000..a857a91 --- /dev/null +++ b/src/main/java/zuulserver/JwtAlgorithms.java @@ -0,0 +1,79 @@ +/* + * Copyright 2006-2011 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package zuulserver; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.security.jwt.crypto.cipher.CipherMetadata; + +/** + * @author Luke Taylor + */ +public class JwtAlgorithms { + private static final Map sigAlgs = new HashMap(); + private static final Map javaToSigAlgs = new HashMap(); + private static final Map keyAlgs = new HashMap(); + private static final Map javaToKeyAlgs = new HashMap(); + + static { + sigAlgs.put("HS256", "HMACSHA256"); + sigAlgs.put("HS384" , "HMACSHA384"); + sigAlgs.put("HS512" , "HMACSHA512"); + sigAlgs.put("RS256" , "SHA256withRSA"); + sigAlgs.put("RS512" , "SHA512withRSA"); + + keyAlgs.put("RSA1_5" , "RSA/ECB/PKCS1Padding"); + + for(Map.Entry e: sigAlgs.entrySet()) { + javaToSigAlgs.put(e.getValue(), e.getKey()); + } + for(Map.Entry e: keyAlgs.entrySet()) { + javaToKeyAlgs.put(e.getValue(), e.getKey()); + } + + } + + static String sigAlg(String javaName){ + String alg = javaToSigAlgs.get(javaName); + + if (alg == null) { + throw new IllegalArgumentException("Invalid or unsupported signature algorithm: " + javaName); + } + + return alg; + } + + static String keyEncryptionAlg(String javaName) { + String alg = javaToKeyAlgs.get(javaName); + + if (alg == null) { + throw new IllegalArgumentException("Invalid or unsupported key encryption algorithm: " + javaName); + } + + return alg; + } + + static String enc(CipherMetadata cipher) { + if (!cipher.algorithm().equalsIgnoreCase("AES/CBC/PKCS5Padding")) { + throw new IllegalArgumentException("Unknown or unsupported algorithm"); + } + if (cipher.keySize() == 128) { + return "A128CBC"; + } else if (cipher.keySize() == 256) { + return "A256CBC"; + } else { + throw new IllegalArgumentException("Unsupported key size"); + } + } +} \ No newline at end of file diff --git a/src/main/java/zuulserver/JwtHelper.java b/src/main/java/zuulserver/JwtHelper.java new file mode 100755 index 0000000..4148e07 --- /dev/null +++ b/src/main/java/zuulserver/JwtHelper.java @@ -0,0 +1,327 @@ +/* + * Copyright 2006-2011 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package zuulserver; + +import static org.springframework.security.jwt.codec.Codecs.b64UrlDecode; +import static org.springframework.security.jwt.codec.Codecs.b64UrlEncode; +import static org.springframework.security.jwt.codec.Codecs.concat; +import static org.springframework.security.jwt.codec.Codecs.utf8Decode; +import static org.springframework.security.jwt.codec.Codecs.utf8Encode; + +import java.nio.CharBuffer; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; +import org.springframework.security.jwt.BinaryFormat; +import org.springframework.security.jwt.Jwt; + +import org.springframework.security.jwt.crypto.sign.SignatureVerifier; +import org.springframework.security.jwt.crypto.sign.Signer; +import static zuulserver.JwtAlgorithms.sigAlg; + +/** + * @author Luke Taylor + * @author Dave Syer + */ +public class JwtHelper { + static byte[] PERIOD = utf8Encode("."); + + /** + * Creates a token from an encoded token string. + * + * @param token the (non-null) encoded token (three Base-64 encoded strings separated + * by "." characters) + */ + public static Jwt decode(String token) { + int firstPeriod = token.indexOf('.'); + int lastPeriod = token.lastIndexOf('.'); + + if (firstPeriod <= 0 || lastPeriod <= firstPeriod) { + throw new IllegalArgumentException("JWT must have 3 tokens"); + } + CharBuffer buffer = CharBuffer.wrap(token, 0, firstPeriod); + // TODO: Use a Reader which supports CharBuffer + JwtHeader header = JwtHeaderHelper.create(buffer.toString()); + + buffer.limit(lastPeriod).position(firstPeriod + 1); + byte[] claims = b64UrlDecode(buffer); + boolean emptyCrypto = lastPeriod == token.length() - 1; + + byte[] crypto; + + if (emptyCrypto) { + if (!"none".equals(header.parameters.alg)) { + throw new IllegalArgumentException( + "Signed or encrypted token must have non-empty crypto segment"); + } + crypto = new byte[0]; + } + else { + buffer.limit(token.length()).position(lastPeriod + 1); + crypto = b64UrlDecode(buffer); + } + return new JwtImpl(header, claims, crypto); + } + + public static Jwt decodeAndVerify(String token, SignatureVerifier verifier) { + Jwt jwt = decode(token); + jwt.verifySignature(verifier); + + return jwt; + } + + public static Map headers(String token) { + JwtImpl jwt = (JwtImpl) decode(token); + Map map = new LinkedHashMap(jwt.header.parameters.map); + map.put("alg", jwt.header.parameters.alg); + if (jwt.header.parameters.typ!=null) { + map.put("typ", jwt.header.parameters.typ); + } + return map; + } + + public static Jwt encode(CharSequence content, Signer signer) { + return encode(content, signer, Collections.emptyMap()); + } + + public static Jwt encode(CharSequence content, Signer signer, + Map headers) { + JwtHeader header = JwtHeaderHelper.create(signer, headers); + byte[] claims = utf8Encode(content); + byte[] crypto = signer + .sign(concat(b64UrlEncode(header.bytes()), PERIOD, b64UrlEncode(claims))); + return new JwtImpl(header, claims, crypto); + } +} + +/** + * Helper object for JwtHeader. + * + * Handles the JSON parsing and serialization. + */ +class JwtHeaderHelper { + + static JwtHeader create(String header) { + byte[] bytes = b64UrlDecode(header); + return new JwtHeader(bytes, parseParams(bytes)); + } + + static JwtHeader create(Signer signer, Map params) { + Map map = new LinkedHashMap(params); + map.put("alg", sigAlg(signer.algorithm())); + HeaderParameters p = new HeaderParameters(map); + return new JwtHeader(serializeParams(p), p); + } + + static HeaderParameters parseParams(byte[] header) { + Map map = parseMap(utf8Decode(header)); + return new HeaderParameters(map); + } + + private static Map parseMap(String json) { + if (json != null) { + json = json.trim(); + if (json.startsWith("{")) { + return parseMapInternal(json); + } + else if (json.equals("")) { + return new LinkedHashMap(); + } + } + throw new IllegalArgumentException("Invalid JSON (null)"); + } + + private static Map parseMapInternal(String json) { + Map map = new LinkedHashMap(); + json = trimLeadingCharacter(trimTrailingCharacter(json, '}'), '{'); + for (String pair : json.split(",")) { + String[] values = pair.split(":"); + String key = strip(values[0], '"'); + String value = null; + if (values.length > 0) { + value = strip(values[1], '"'); + } + if (map.containsKey(key)) { + throw new IllegalArgumentException("Duplicate '" + key + "' field"); + } + map.put(key, value); + } + return map; + } + + private static String strip(String string, char c) { + return trimLeadingCharacter(trimTrailingCharacter(string.trim(), c), c); + } + + private static String trimTrailingCharacter(String string, char c) { + if (string.length() >= 0 && string.charAt(string.length() - 1) == c) { + return string.substring(0, string.length() - 1); + } + return string; + } + + private static String trimLeadingCharacter(String string, char c) { + if (string.length() >= 0 && string.charAt(0) == c) { + return string.substring(1); + } + return string; + } + + private static byte[] serializeParams(HeaderParameters params) { + StringBuilder builder = new StringBuilder("{"); + + appendField(builder, "alg", params.alg); + if (params.typ != null) { + appendField(builder, "typ", params.typ); + } + for (Entry entry : params.map.entrySet()) { + appendField(builder, entry.getKey(), entry.getValue()); + } + builder.append("}"); + return utf8Encode(builder.toString()); + + } + + private static void appendField(StringBuilder builder, String name, String value) { + if (builder.length() > 1) { + builder.append(","); + } + builder.append("\"").append(name).append("\":\"").append(value).append("\""); + } +} + +/** + * Header part of JWT + * + */ +class JwtHeader implements BinaryFormat { + private final byte[] bytes; + + final HeaderParameters parameters; + + /** + * @param bytes the decoded header + * @param parameters the parameter values contained in the header + */ + JwtHeader(byte[] bytes, HeaderParameters parameters) { + this.bytes = bytes; + this.parameters = parameters; + } + + @Override + public byte[] bytes() { + return bytes; + } + + @Override + public String toString() { + return utf8Decode(bytes); + } +} + +class HeaderParameters { + final String alg; + + final Map map; + + final String typ = "JWT"; + + HeaderParameters(String alg) { + this(new LinkedHashMap(Collections.singletonMap("alg", alg))); + } + + HeaderParameters(Map map) { + String alg = map.get("alg"), typ = map.get("typ"); + if (typ != null && !"JWT".equalsIgnoreCase(typ)) { + throw new IllegalArgumentException("typ is not \"JWT\""); + } + map.remove("alg"); + map.remove("typ"); + this.map = map; + if (alg == null) { + throw new IllegalArgumentException("alg is required"); + } + this.alg = alg; + } + +} + +class JwtImpl implements Jwt { + final JwtHeader header; + + private final byte[] content; + + private final byte[] crypto; + + private String claims; + + /** + * @param header the header, containing the JWS/JWE algorithm information. + * @param content the base64-decoded "claims" segment (may be encrypted, depending on + * header information). + * @param crypto the base64-decoded "crypto" segment. + */ + JwtImpl(JwtHeader header, byte[] content, byte[] crypto) { + this.header = header; + this.content = content; + this.crypto = crypto; + claims = utf8Decode(content); + } + + /** + * Validates a signature contained in the 'crypto' segment. + * + * @param verifier the signature verifier + */ + @Override + public void verifySignature(SignatureVerifier verifier) { + verifier.verify(signingInput(), crypto); + } + + private byte[] signingInput() { + return concat(b64UrlEncode(header.bytes()), JwtHelper.PERIOD, + b64UrlEncode(content)); + } + + /** + * Allows retrieval of the full token. + * + * @return the encoded header, claims and crypto segments concatenated with "." + * characters + */ + @Override + public byte[] bytes() { + return concat(b64UrlEncode(header.bytes()), JwtHelper.PERIOD, + b64UrlEncode(content), JwtHelper.PERIOD, b64UrlEncode(crypto)); + } + + @Override + public String getClaims() { + return utf8Decode(content); + } + + @Override + public String getEncoded() { + return utf8Decode(bytes()); + } + + public JwtHeader header() { + return this.header; + } + + @Override + public String toString() { + return header + " " + claims + " [" + crypto.length + " crypto bytes]"; + } +} \ No newline at end of file diff --git a/src/main/java/zuulserver/ZuulServerApplication.java b/src/main/java/zuulserver/ZuulServerApplication.java new file mode 100755 index 0000000..be52e6d --- /dev/null +++ b/src/main/java/zuulserver/ZuulServerApplication.java @@ -0,0 +1,34 @@ +package zuulserver; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.cloud.netflix.zuul.EnableZuulProxy; +import org.springframework.context.annotation.Bean; +import zuulserver.filters.MyZuulFilter; + +/** + * @author Spencer Gibb + */ +@SpringBootApplication +@EnableCaching +@EnableZuulProxy +public class ZuulServerApplication { + + public static void main(String[] args) { + new SpringApplicationBuilder(ZuulServerApplication.class).web(true).run(args); + } + + @Bean + public MyZuulFilter myZuulFilter() { + return new MyZuulFilter(); + } + + @Bean + public CacheManager cacheManager() { + return new ConcurrentMapCacheManager("permissions"); + } + +} diff --git a/src/main/java/zuulserver/config/MyConfiguration.java b/src/main/java/zuulserver/config/MyConfiguration.java new file mode 100755 index 0000000..996e3a4 --- /dev/null +++ b/src/main/java/zuulserver/config/MyConfiguration.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017 ELIXIR EGA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package zuulserver.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * + * @author asenf + */ +@Configuration +public class MyConfiguration { + @Value("${ega.server.internal}") String configInternal; + @Value("${ega.server.url}") String configUrl; + @Value("${ega.server.token}") String configToken; + @Value("${spring.oauth2.resource.userInfoUri}") String elixirUserInfo; + @Value("${manual.basic.user}") String basicUser; + @Value("${manual.basic.password}") String basicPass; + + @Bean + public MyServerSettings MyServerSettings() { + return new MyServerSettings(configInternal, configUrl, configToken, elixirUserInfo, basicUser, basicPass); + } +} diff --git a/src/main/java/zuulserver/config/MyServerSettings.java b/src/main/java/zuulserver/config/MyServerSettings.java new file mode 100755 index 0000000..9d8045f --- /dev/null +++ b/src/main/java/zuulserver/config/MyServerSettings.java @@ -0,0 +1,67 @@ +/* + * Copyright 2017 ELIXIR EGA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package zuulserver.config; + +/** + * + * @author asenf + */ +public class MyServerSettings { + private final String internal; + private final String url; + private final String token; + private final String elixirUrl; + private final String basicUser; + private final String basicPass; + + public MyServerSettings(String internal, + String url, + String token, + String elixirUrl, + String basicUser, + String basicPass) { + this.internal = internal; + this.url = url; + this.token = token; + this.elixirUrl = elixirUrl; + this.basicUser = basicUser; + this.basicPass = basicPass; + } + + public boolean isInternal() { + return this.internal.equalsIgnoreCase("true"); + } + + public String getUrl() { + return this.url; + } + + public String getToken() { + return this.token; + } + + public String getElixirUrl() { + return this.elixirUrl; + } + + public String getBasicUser() { + return this.basicUser; + } + + public String getBasicPass() { + return this.basicPass; + } +} diff --git a/src/main/java/zuulserver/filters/MyZuulFilter.java b/src/main/java/zuulserver/filters/MyZuulFilter.java new file mode 100755 index 0000000..42b243d --- /dev/null +++ b/src/main/java/zuulserver/filters/MyZuulFilter.java @@ -0,0 +1,220 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package zuulserver.filters; + +import com.netflix.zuul.ZuulFilter; +import com.netflix.zuul.context.RequestContext; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.Types; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Random; + +import okhttp3.Cache; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import org.apache.commons.lang.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; + +import org.springframework.stereotype.Component; +import org.springframework.boot.autoconfigure.security.oauth2.resource.UserInfoTokenServices; +import org.springframework.security.crypto.codec.Base64; +import org.springframework.security.jwt.Jwt; +import org.springframework.security.jwt.JwtHelper; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.web.util.UrlPathHelper; +import zuulserver.config.MyServerSettings; +/** + * + * @author asenf + */ +@Component +public class MyZuulFilter extends ZuulFilter { + + private UrlPathHelper urlPathHelper = new UrlPathHelper(); + + @Autowired + private MyServerSettings serverSettings; + + @Override + public String filterType() { + return "pre"; + } + + @Override + public int filterOrder() { + return 0; + } + + @Override + public boolean shouldFilter() { + return true; + } + + @Override + public Object run() { + RequestContext ctx = RequestContext.getCurrentContext(); + final String requestURI = this.urlPathHelper.getPathWithinApplication(ctx.getRequest()); + if (!(requestURI.contains("data") || + requestURI.contains("central"))) { + throw new NullPointerException(); + + } + + String access_token = ""; + try { + access_token = StringUtils.substringAfterLast((String)ctx.getRequest().getHeader("Authorization"), "Bearer "); + } catch (Throwable th) { + } + + // Only perform this for ELIXIR tokens - pass EGA tokens straight to the API + boolean isElixirToken = determineTokenOrigin(access_token); + if (isElixirToken) { + String userElixirSub = getElixirSub(access_token); + + // Contact EGA for Datasets & Elixir User + List permissions = getElixirPermissions(userElixirSub); + String permissions_ = ""; + for (int i=0; i details = (LinkedHashMap) auth.getUserAuthentication().getDetails(); + String email = details.get("email"); + + return email; + } + + private String getElixirUserId(String accessToken) { + String url = serverSettings.getElixirUrl(); + UserInfoTokenServices tokenService = new UserInfoTokenServices(url, ""); + OAuth2Authentication auth = tokenService.loadAuthentication(accessToken); + LinkedHashMap details = (LinkedHashMap) auth.getUserAuthentication().getDetails(); + String id = details.get("id"); + + return id; + } + + private String getElixirSub(String accessToken) { + String url = serverSettings.getElixirUrl(); + UserInfoTokenServices tokenService = new UserInfoTokenServices(url, ""); + OAuth2Authentication auth = tokenService.loadAuthentication(accessToken); + LinkedHashMap details = (LinkedHashMap) auth.getUserAuthentication().getDetails(); + String sub = details.get("sub"); + + return sub; + } + + private List getElixirPermissions(String user_id) { + String baseApi = serverSettings.isInternal()?getInternalUrl():serverSettings.getElixirUrl(); // MUST BE CHANGED TO "https://ega.ebi.ac.uk/elixir/central/.." IN LOCAL (NON-EBI) INSTALLATIONS + String accessToken = serverSettings.getToken(); + + OkHttpClient.Builder builder = new OkHttpClient.Builder(); + OkHttpClient okHttpClient = builder + .addNetworkInterceptor(new ResponseCacheInterceptor()) // Enable response caching + .cache(new Cache(new File("apiResponses"), 5 * 1024 * 1024)) // Set the cache location and size (5 MB) + .build(); + + // List all Datasets + Request datasetRequest = new Request.Builder() + .url(baseApi + "/app/elixir/" + user_id + "/datasets") + //.addHeader("Authorization", "Bearer " + accessToken) + .addHeader("Authorization", "Basic " + getBasicEncoded(serverSettings.getBasicUser(), serverSettings.getBasicPass())) + .build(); + Moshi MOSHI = new Moshi.Builder().build(); + JsonAdapter> STRING_JSON_ADAPTER = + MOSHI.adapter(Types.newParameterizedType(List.class, String.class)); + + List result = new ArrayList(); // Prevent result from bein Null + try { + // Execute the request and retrieve the response. + Response response = null; + int tryCount = 9; + while (tryCount-->0 && (response == null || !response.isSuccessful())) { + try { + response = okHttpClient.newCall(datasetRequest).execute(); + } catch (Exception ex) { + } + } + ResponseBody body = response.body(); + result = STRING_JSON_ADAPTER.fromJson(body.source()); + body.close(); + System.out.println(result.size() + " Datasets."); + + } catch (IOException ex) { + System.out.println("Error getting Datasets: " + ex.toString()); + } + + return result; + } + + // Returns 'true' if token originated from ELIXIR + private boolean determineTokenOrigin(String access_token) { + if (access_token==null || access_token.length()==0) + return false; + + Jwt decoded_token = JwtHelper.decode(access_token); + String claims = decoded_token.getClaims(); + return (claims.contains("perun.elixir-czech.cz")); + } + + // Returns URL for CENTRAL (Internal Only) + private String getInternalUrl() { + String[] baseApi = {"http://pg-ega-pro-06:9153", + "http://pg-ega-pro-07:9153", + "http://pg-ega-pro-08:9153"}; + + Random r = new Random(); + int index = r.nextInt(3); + return baseApi[index]; + } + + /** + * Interceptor to cache data and maintain it for 10 minutes. + * + * If the same network request is sent within a minute, + * the response is retrieved from cache. + */ + private static class ResponseCacheInterceptor implements Interceptor { + @Override + public okhttp3.Response intercept(Chain chain) throws IOException { + okhttp3.Response originalResponse = chain.proceed(chain.request()); + return originalResponse.newBuilder() + .header("Cache-Control", "public, max-age=" + 600) + .build(); + } + } + + private static String getBasicEncoded(String user, String password) { + String authString = user + ":" + password; + System.out.println("auth string: " + authString); + byte[] authEncBytes = Base64.encode(authString.getBytes()); + String authStringEnc = new String(authEncBytes); + System.out.println("Base64 encoded auth string: " + authStringEnc); + return authStringEnc; + } +} diff --git a/src/main/resources/bootstrap.properties b/src/main/resources/bootstrap.properties new file mode 100755 index 0000000..3b2800b --- /dev/null +++ b/src/main/resources/bootstrap.properties @@ -0,0 +1,2 @@ +spring.cloud.config.uri=http://pg-ega-pro-04.ebi.ac.uk:8888 +spring.application.name=zuulserver diff --git a/zuulserver.properties b/zuulserver.properties new file mode 100755 index 0000000..dba17e8 --- /dev/null +++ b/zuulserver.properties @@ -0,0 +1,45 @@ +spring.application.name= zuulserver + +server.port = 8051 +server.ssl.enabled: true +server.ssl.key-alias: ..... +server.ssl.key-store: ..... +server.ssl.key-store-password: .... + +security.basic.enabled = false + +#ELIXIR OpenID Connect AAI IdP +spring.oauth2.resource.userInfoUri = https://perun.elixir-czech.cz/oauth/rpc/json/oidcManager/userinfo + +auth.server.url: https://perun.elixir-czech.cz/oidc/token +auth.server.clientId: client +auth.server.clientsecret: secret + +spring.oauth2.resource.preferTokenInfo = false + +#Local Eureka +eureka.name = sampleRegisteringService +eureka.port = 8761 +eureka.vipAddress = pg-ega-pro-06.ebi.ac.uk +eureka.serviceUrls = http://10.50.10.16:8761/eureka/ +eureka.client.serviceUrl.defaultZone: http://pg-ega-pro-06.ebi.ac.uk:8761/eureka/ +eureka.instance.preferIpAddress: true + +#ZUUL properties +info.component: Zuul Server + +zuul.prefix: /elixir +zuul.ignoredServices: '*' +zuul.routes.access.path: /access/** +zuul.routes.access.serviceId: ACCESS +zuul.routes.access.sensitive-headers=Cookie,Set-Cookie +zuul.routes.dsedge.path: /data/** +zuul.routes.dsedge.serviceId: DSEDGE +zuul.routes.dsedge.sensitive-headers=Cookie,Set-Cookie +zuul.routes.zuulserver: /self/** + +# Increase the Hystrix timeout to 60s (globally) +hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds: 60000 + +zuulserver.ribbon.ConnectTimeout: 3000 +zuulserver.ribbon.ReadTimeout: 60000