diff --git a/auth/pom.xml b/auth/pom.xml index 087976b..5e66cfb 100644 --- a/auth/pom.xml +++ b/auth/pom.xml @@ -18,6 +18,11 @@ ${project.parent.version} compile + + com.gewia.common + scope + ${project.parent.version} + com.auth0 diff --git a/auth/src/main/java/com/gewia/common/auth/jwt/Jwt.java b/auth/src/main/java/com/gewia/common/auth/jwt/Jwt.java new file mode 100644 index 0000000..fd4f8f3 --- /dev/null +++ b/auth/src/main/java/com/gewia/common/auth/jwt/Jwt.java @@ -0,0 +1,14 @@ +package com.gewia.common.auth.jwt; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import java.util.UUID; + +@Getter +@AllArgsConstructor +public class Jwt { + + private final UUID userId; + private final JwtScopes userScopes; + +} diff --git a/auth/src/main/java/com/gewia/common/auth/jwt/JwtScopes.java b/auth/src/main/java/com/gewia/common/auth/jwt/JwtScopes.java new file mode 100644 index 0000000..160bacd --- /dev/null +++ b/auth/src/main/java/com/gewia/common/auth/jwt/JwtScopes.java @@ -0,0 +1,43 @@ +package com.gewia.common.auth.jwt; + +import com.gewia.common.util.Executor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class JwtScopes { + + @Getter private List scopes = new ArrayList<>(); + private boolean containing = false; + + public JwtScopes(List scopes) { + this.scopes = Collections.unmodifiableList(scopes); + } + + public JwtScopes includes(String scope, Executor executor) { + if (this.scopes.contains(scope)) { + containing = true; + if (executor != null) executor.action(); + } else containing = false; + + return this; + } + + public void orElse(Executor executor) { + if (!containing && executor != null) executor.action(); + containing = false; + } + + public boolean getResult() { + return containing; + } + + public boolean hasScope(String scope) { + return this.scopes.contains(scope); + } + +} diff --git a/auth/src/test/java/com/gewia/common/auth/jwt/JwtScopesTest.java b/auth/src/test/java/com/gewia/common/auth/jwt/JwtScopesTest.java new file mode 100644 index 0000000..c0c2c44 --- /dev/null +++ b/auth/src/test/java/com/gewia/common/auth/jwt/JwtScopesTest.java @@ -0,0 +1,60 @@ +package com.gewia.common.auth.jwt; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Assert; +import org.junit.Test; + +public class JwtScopesTest { + + @Test + public void testScopesUnmodifiable() { + JwtScopes jwtScopes = this.getExampleJwtScopes(); + + Assert.assertThrows(UnsupportedOperationException.class, () -> jwtScopes.getScopes().add("another.example.scope")); + } + + @Test + public void testResultOperation() { + JwtScopes jwtScopes = this.getExampleJwtScopes(); + + Assert.assertTrue(jwtScopes.includes("microservice.topic.mode.limitation.extra", null).getResult()); + Assert.assertFalse(jwtScopes.includes("another.exmaple.scope", null).getResult()); + } + + @Test + public void testOrElseOperation() { + JwtScopes jwtScopes = this.getExampleJwtScopes(); + + AtomicInteger i = new AtomicInteger(); + jwtScopes.includes("another.example.scope", i::getAndIncrement).orElse(i::getAndDecrement); + Assert.assertEquals(-1, i.get()); + } + + @Test + public void testIncludeOperation() { + JwtScopes jwtScopes = this.getExampleJwtScopes(); + + AtomicInteger i = new AtomicInteger(); + jwtScopes.includes("microservice.topic.mode.limitation.extra", i::getAndIncrement).orElse(i::getAndDecrement); + Assert.assertEquals(1, i.get()); + } + + @Test + public void testInitializing() { + List exampleScopes = new ArrayList<>(); + exampleScopes.add("microservice.topic.mode.limitation.extra"); + JwtScopes jwtScopes = new JwtScopes(exampleScopes); + + Assert.assertEquals(exampleScopes, jwtScopes.getScopes()); + } + + private JwtScopes getExampleJwtScopes() { + List exampleScopes = new ArrayList<>(); + exampleScopes.add("microservice.topic.mode.limitation.extra"); + + return new JwtScopes(exampleScopes); + } + +} diff --git a/pom.xml b/pom.xml index c4a279a..c8ff076 100644 --- a/pom.xml +++ b/pom.xml @@ -13,6 +13,7 @@ scope auth util + spring-auth diff --git a/spring-auth/pom.xml b/spring-auth/pom.xml new file mode 100644 index 0000000..96526e7 --- /dev/null +++ b/spring-auth/pom.xml @@ -0,0 +1,77 @@ + + + + gewia-common + com.gewia.common + 1.0 + + 4.0.0 + spring-auth + + + + + org.springframework.boot + spring-boot-dependencies + 2.1.10.RELEASE + pom + import + + + + + + + com.gewia.common + auth + ${project.parent.version} + compile + + + com.gewia.common + scope + ${project.parent.version} + compile + + + + com.auth0 + java-jwt + 3.10.3 + compile + + + + javax.validation + validation-api + 2.0.1.Final + compile + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + + + + + + \ No newline at end of file diff --git a/spring-auth/src/main/java/com/gewia/common/spring/auth/AuthScope.java b/spring-auth/src/main/java/com/gewia/common/spring/auth/AuthScope.java new file mode 100644 index 0000000..8a1116e --- /dev/null +++ b/spring-auth/src/main/java/com/gewia/common/spring/auth/AuthScope.java @@ -0,0 +1,18 @@ +package com.gewia.common.spring.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@Repeatable(Authentication.class) +public @interface AuthScope { + + String value() default ""; + + String scope() default ""; + +} diff --git a/spring-auth/src/main/java/com/gewia/common/spring/auth/Authentication.java b/spring-auth/src/main/java/com/gewia/common/spring/auth/Authentication.java new file mode 100644 index 0000000..cb596e9 --- /dev/null +++ b/spring-auth/src/main/java/com/gewia/common/spring/auth/Authentication.java @@ -0,0 +1,14 @@ +package com.gewia.common.spring.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Authentication { + + AuthScope[] value(); + +} diff --git a/spring-auth/src/main/java/com/gewia/common/spring/auth/IgnoreServiceToken.java b/spring-auth/src/main/java/com/gewia/common/spring/auth/IgnoreServiceToken.java new file mode 100644 index 0000000..3216d4c --- /dev/null +++ b/spring-auth/src/main/java/com/gewia/common/spring/auth/IgnoreServiceToken.java @@ -0,0 +1,12 @@ +package com.gewia.common.spring.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +public @interface IgnoreServiceToken { + +} diff --git a/spring-auth/src/main/java/com/gewia/common/spring/auth/SpringAuthentication.java b/spring-auth/src/main/java/com/gewia/common/spring/auth/SpringAuthentication.java new file mode 100644 index 0000000..219c068 --- /dev/null +++ b/spring-auth/src/main/java/com/gewia/common/spring/auth/SpringAuthentication.java @@ -0,0 +1,23 @@ +package com.gewia.common.spring.auth; + +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; + +@ComponentScan("com.gewia.common.spring.auth") +public abstract class SpringAuthentication implements InitializingBean { + + @Getter(AccessLevel.PACKAGE) private static List interceptors = new ArrayList<>(); + + @Override + public void afterPropertiesSet() { + this.addAuthenticationInterceptors(interceptors); + } + + abstract public void addAuthenticationInterceptors(List authenticationInterceptors); + +} diff --git a/spring-auth/src/main/java/com/gewia/common/spring/auth/SpringAuthenticationWebConfig.java b/spring-auth/src/main/java/com/gewia/common/spring/auth/SpringAuthenticationWebConfig.java new file mode 100644 index 0000000..afb427b --- /dev/null +++ b/spring-auth/src/main/java/com/gewia/common/spring/auth/SpringAuthenticationWebConfig.java @@ -0,0 +1,48 @@ +package com.gewia.common.spring.auth; + +import java.util.List; +import java.util.UUID; +import javax.servlet.http.HttpServletRequest; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.gewia.common.auth.jwt.Jwt; +import com.gewia.common.auth.jwt.JwtScopes; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; + +@Configuration +@EnableWebMvc +public class SpringAuthenticationWebConfig implements WebMvcConfigurer, HandlerMethodArgumentResolver { + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + DecodedJWT decodedJWT = (DecodedJWT) ((HttpServletRequest) webRequest.getNativeRequest()).getAttribute("accessToken"); + + return new Jwt(UUID.fromString(decodedJWT.getClaim("userId").asString()), + new JwtScopes(decodedJWT.getClaim("scopes").asList(String.class))); + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(Jwt.class); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(this); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + for (HandlerInterceptorAdapter interceptors : SpringAuthentication.getInterceptors()) + registry.addInterceptor(interceptors).addPathPatterns("/**/*"); + } + +} diff --git a/spring-auth/src/main/java/com/gewia/common/spring/auth/interceptor/ScopeInterceptor.java b/spring-auth/src/main/java/com/gewia/common/spring/auth/interceptor/ScopeInterceptor.java new file mode 100644 index 0000000..e89c751 --- /dev/null +++ b/spring-auth/src/main/java/com/gewia/common/spring/auth/interceptor/ScopeInterceptor.java @@ -0,0 +1,78 @@ +package com.gewia.common.spring.auth.interceptor; + +import com.auth0.jwt.interfaces.Claim; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.gewia.common.auth.jwt.JwtUtil; +import com.gewia.common.spring.auth.AuthScope; +import com.gewia.common.spring.auth.Authentication; +import com.gewia.common.util.Pair; +import java.util.List; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; + +@AllArgsConstructor +public class ScopeInterceptor extends HandlerInterceptorAdapter { + + private final JwtUtil jwtUtil; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + HandlerMethod method = (HandlerMethod) handler; + + AuthScope[] authScopes; + Authentication auth = method.getMethodAnnotation(Authentication.class); + AuthScope methodAuthScope = method.getMethodAnnotation(AuthScope.class); + if (auth != null) authScopes = auth.value(); + else { + if (methodAuthScope == null) return true; + authScopes = new AuthScope[]{methodAuthScope}; + } + + + String jwt = request.getHeader("Authorization"); + if (jwt == null || jwt.isBlank()) return false; + + Pair result = this.jwtUtil.verify(jwt); + switch (result.getRight()) { + case EXPIRED: + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + return false; + case INVALID: + response.setStatus(HttpStatus.NOT_ACCEPTABLE.value()); + return false; + case FAILED: + response.setStatus(HttpStatus.EXPECTATION_FAILED.value()); + return false; + case UNKNOWN: + response.setStatus(HttpStatus.FORBIDDEN.value()); + return false; + default: + response.setStatus(HttpStatus.OK.value()); + } + + Claim claim = result.getLeft().getClaim("scopes"); + List userScopes = claim.asList(String.class); + for (AuthScope authScope : authScopes) { + String scope = authScope.scope(); + if (scope.isBlank()) scope = authScope.value(); + if (!scope.isBlank()) { + boolean isPresent = false; + for (String userScope : userScopes) + if (userScope.equalsIgnoreCase(scope)) { + isPresent = true; + break; + } + if (!isPresent) return false; + } + } + + request.setAttribute("accessToken", result.getLeft()); + + return true; + } + +} diff --git a/spring-auth/src/main/java/com/gewia/common/spring/auth/interceptor/ServiceTokenInterceptor.java b/spring-auth/src/main/java/com/gewia/common/spring/auth/interceptor/ServiceTokenInterceptor.java new file mode 100644 index 0000000..42212a4 --- /dev/null +++ b/spring-auth/src/main/java/com/gewia/common/spring/auth/interceptor/ServiceTokenInterceptor.java @@ -0,0 +1,36 @@ +package com.gewia.common.spring.auth.interceptor; + +import com.gewia.common.spring.auth.IgnoreServiceToken; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; + +@AllArgsConstructor +public class ServiceTokenInterceptor extends HandlerInterceptorAdapter { + + private final String serviceToken; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + response.setStatus(HttpStatus.FORBIDDEN.value()); + + HandlerMethod method = (HandlerMethod) handler; + if (method.hasMethodAnnotation(IgnoreServiceToken.class) || + method.getMethod().getDeclaringClass().getAnnotation(IgnoreServiceToken.class) != null) { + response.setStatus(HttpStatus.OK.value()); + return true; + } + + String serviceToken = request.getHeader("X-ServiceToken"); + + if (serviceToken == null) return false; + if (!this.serviceToken.equals(serviceToken)) return false; + + response.setStatus(HttpStatus.OK.value()); + return true; + } + +} diff --git a/util/src/main/java/com/gewia/common/util/Executor.java b/util/src/main/java/com/gewia/common/util/Executor.java new file mode 100644 index 0000000..06ad4e2 --- /dev/null +++ b/util/src/main/java/com/gewia/common/util/Executor.java @@ -0,0 +1,8 @@ +package com.gewia.common.util; + +@FunctionalInterface +public interface Executor { + + void action(); + +}