OPS-104: Initial implementation (#1)

This commit is contained in:
malkoas 2022-06-29 14:34:02 +03:00 committed by GitHub
parent 39ee3b689b
commit fc68854413
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 2104 additions and 0 deletions

2
.github/settings.yml vendored Normal file
View File

@ -0,0 +1,2 @@
# These settings are synced to GitHub by https://probot.github.io/apps/settings/
_extends: .github

10
.github/workflows/basic-linters.yml vendored Normal file
View File

@ -0,0 +1,10 @@
name: Vality basic linters
on:
pull_request:
branches:
- "*"
jobs:
lint:
uses: valitydev/base-workflows/.github/workflows/basic-linters.yml@v1

10
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,10 @@
name: Build Artifact
on:
pull_request:
branches:
- '*'
jobs:
build:
uses: valitydev/base-workflow/.github/workflows/maven-service-build.yml@v1

18
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,18 @@
name: Deploy Docker Image
on:
push:
branches:
- 'master'
- 'main'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
deploy:
uses: valitydev/base-workflow/.github/workflows/maven-service-deploy.yml@v1
secrets:
github-token: ${{ secrets.GITHUB_TOKEN }}
mm-webhook-url: ${{ secrets.MATTERMOST_WEBHOOK_URL }}

78
.gitignore vendored Normal file
View File

@ -0,0 +1,78 @@
# Created by .ignore support plugin (hsz.mobi)
.eunit
deps
*.o
*.beam
*.plt
erl_crash.dump
ebin/*.beam
rel/example_project
.concrete/DEV_MODE
.rebar
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff:
.idea/
.idea/workspace.xml
.idea/tasks.xml
.idea/dictionaries
.idea/vcs.xml
.idea/jsLibraryMappings.xml
# Sensitive or high-churn files:
.idea/dataSources.ids
.idea/dataSources.xml
.idea/dataSources.local.xml
.idea/sqlDataSources.xml
.idea/dynamic.xml
.idea/uiDesigner.xml
# Gradle:
.idea/gradle.xml
.idea/libraries
# Mongo Explorer plugin:
.idea/mongoSettings.xml
*.iws
*.ipr
*.iml
# IntelliJ
/out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
*.class
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.ear
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
env.list

247
pom.xml Normal file
View File

@ -0,0 +1,247 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.vality</groupId>
<artifactId>service-parent-pom</artifactId>
<version>1.0.17</version>
</parent>
<artifactId>wachter</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>15</java.version>
<server.port>8022</server.port>
<management.port>8023</management.port>
<exposed.ports>${server.port} ${management.port}</exposed.ports>
<jackson-version>2.12.5</jackson-version>
<spring-version>2.5.3</spring-version>
<javax-annotation-api-version>1.3.2</javax-annotation-api-version>
<jaxb-version>2.3.1</jaxb-version>
</properties>
<dependencies>
<!--vality-->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>dev.vality</groupId>
<artifactId>shared-resources</artifactId>
</dependency>
<dependency>
<groupId>dev.vality</groupId>
<artifactId>bouncer-proto</artifactId>
<version>1.39-633ba73</version>
</dependency>
<dependency>
<groupId>dev.vality</groupId>
<artifactId>org-management-proto</artifactId>
<version>1.10-f433223</version>
</dependency>
<dependency>
<groupId>dev.vality.geck</groupId>
<artifactId>serializer</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>dev.vality</groupId>
<artifactId>damsel</artifactId>
</dependency>
<!--spring-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-client</artifactId>
<version>18.0.0</version>
<exclusions>
<exclusion>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-client</artifactId>
</exclusion>
<exclusion>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-multipart-provider</artifactId>
</exclusion>
<exclusion>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jackson2-provider</artifactId>
</exclusion>
<exclusion>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jaxb-provider</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-spring-security-adapter</artifactId>
<version>18.0.0</version>
<exclusions>
<exclusion>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!--third party-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
</dependency>
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>${javax-annotation-api-version}</version>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>${jaxb-version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.69</version>
</dependency>
<!--test-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-wiremock</artifactId>
<version>3.0.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>${project.build.directory}/maven-shared-archive-resources</directory>
<targetPath>${project.build.directory}</targetPath>
<includes>
<include>Dockerfile</include>
</includes>
<filtering>true</filtering>
</resource>
<resource>
<directory>${project.build.directory}/maven-shared-archive-resources</directory>
<filtering>true</filtering>
<excludes>
<exclude>Dockerfile</exclude>
</excludes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-remote-resources-plugin</artifactId>
<version>1.6.0</version>
<dependencies>
<dependency>
<groupId>org.apache.maven.shared</groupId>
<artifactId>maven-filtering</artifactId>
<version>1.3</version>
</dependency>
</dependencies>
<configuration>
<resourceBundles>
<resourceBundle>dev.vality:shared-resources:${shared-resources.version}</resourceBundle>
</resourceBundles>
<attachToMain>false</attachToMain>
<attachToTest>false</attachToTest>
</configuration>
<executions>
<execution>
<goals>
<goal>process</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

4
renovate.json Normal file
View File

@ -0,0 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["local>valitydev/.github:renovate-config"]
}

View File

@ -0,0 +1,15 @@
package dev.vality.wachter;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
@ServletComponentScan
@SpringBootApplication
public class WachterApplication extends SpringApplication {
public static void main(String[] args) {
SpringApplication.run(WachterApplication.class, args);
}
}

View File

@ -0,0 +1,41 @@
package dev.vality.wachter.client;
import dev.vality.wachter.config.properties.WachterProperties;
import lombok.RequiredArgsConstructor;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.util.EntityUtils;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Enumeration;
@Service
@RequiredArgsConstructor
public class WachterClient {
private final HttpClient httpclient;
public byte[] send(HttpServletRequest request,
byte[] contentData,
WachterProperties.Services service) throws IOException {
HttpPost httppost = new HttpPost(service.getUrl());
setHeader(request, httppost);
httppost.setEntity(new ByteArrayEntity(contentData));
HttpResponse response = httpclient.execute(httppost);
return EntityUtils.toByteArray(response.getEntity());
}
private void setHeader(HttpServletRequest request, HttpPost httppost) {
Enumeration<String> headerNames = request.getHeaderNames();
if (headerNames != null) {
while (headerNames.hasMoreElements()) {
String next = headerNames.nextElement();
httppost.setHeader(next, request.getHeader(next));
}
}
}
}

View File

@ -0,0 +1,75 @@
package dev.vality.wachter.config;
import dev.vality.bouncer.decisions.ArbiterSrv;
import dev.vality.orgmanagement.AuthContextProviderSrv;
import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit;
import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit;
import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit;
import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit;
import dev.vality.woody.thrift.impl.http.THSpawnClientBuilder;
import org.apache.http.HttpRequest;
import org.apache.http.HttpRequestInterceptor;
import org.apache.http.client.HttpClient;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.protocol.HTTP;
import org.apache.http.protocol.HttpContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import java.io.IOException;
import java.util.List;
@Configuration
public class ApplicationConfig {
@Bean
public AuthContextProviderSrv.Iface orgManagerClient(
@Value("${orgManager.url}") Resource resource,
@Value("${orgManager.networkTimeout}") int networkTimeout) throws IOException {
return new THSpawnClientBuilder()
.withNetworkTimeout(networkTimeout)
.withMetaExtensions(List.of(
UserIdentityIdExtensionKit.INSTANCE,
UserIdentityEmailExtensionKit.INSTANCE,
UserIdentityUsernameExtensionKit.INSTANCE,
UserIdentityRealmExtensionKit.INSTANCE))
.withAddress(resource.getURI())
.build(AuthContextProviderSrv.Iface.class);
}
@Bean
public ArbiterSrv.Iface bouncerClient(
@Value("${bouncer.url}") Resource resource,
@Value("${bouncer.networkTimeout}") int networkTimeout) throws IOException {
return new THSpawnClientBuilder()
.withNetworkTimeout(networkTimeout)
.withAddress(resource.getURI())
.build(ArbiterSrv.Iface.class);
}
@Bean
public HttpClient httpclient(@Value("${http-client.connectTimeout}") int connectTimeout,
@Value("${http-client.connectionRequestTimeout}") int connectionRequestTimeout,
@Value("${http-client.socketTimeout}") int socketTimeout) {
return HttpClients.custom()
.setDefaultRequestConfig(RequestConfig
.custom()
.setConnectTimeout(connectTimeout)
.setConnectionRequestTimeout(connectionRequestTimeout)
.setSocketTimeout(socketTimeout)
.build())
.addInterceptorFirst(new ContentLengthHeaderRemover())
.build();
}
private static class ContentLengthHeaderRemover implements HttpRequestInterceptor {
@Override
public void process(HttpRequest request, HttpContext context) {
request.removeHeaders(HTTP.CONTENT_LEN);
}
}
}

View File

@ -0,0 +1,60 @@
package dev.vality.wachter.config;
import dev.vality.wachter.config.properties.KeycloakProperties;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.keycloak.adapters.KeycloakConfigResolver;
import org.keycloak.adapters.KeycloakDeployment;
import org.keycloak.adapters.KeycloakDeploymentBuilder;
import org.keycloak.representations.adapters.config.AdapterConfig;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Configuration
@ConditionalOnProperty(value = "auth.enabled", havingValue = "true")
public class KeycloakConfig {
private final KeycloakProperties keycloakProperties;
@Bean
public KeycloakConfigResolver keycloakConfigResolver() {
return facade -> {
KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(adapterConfig());
deployment.setNotBefore(keycloakProperties.getNotBefore());
return deployment;
};
}
private AdapterConfig adapterConfig() {
if (StringUtils.hasLength(keycloakProperties.getRealmPublicKeyPath())) {
keycloakProperties.setRealmPublicKey(readKeyFromFile(keycloakProperties.getRealmPublicKeyPath()));
}
AdapterConfig adapterConfig = new AdapterConfig();
adapterConfig.setRealm(keycloakProperties.getRealm());
adapterConfig.setRealmKey(keycloakProperties.getRealmPublicKey());
adapterConfig.setResource(keycloakProperties.getResource());
adapterConfig.setAuthServerUrl(keycloakProperties.getAuthServerUrl());
adapterConfig.setUseResourceRoleMappings(true);
adapterConfig.setBearerOnly(true);
adapterConfig.setSslRequired(keycloakProperties.getSslRequired());
return adapterConfig;
}
@SneakyThrows
private String readKeyFromFile(String filePath) {
List<String> strings = Files.readAllLines(Paths.get(filePath));
strings.remove(strings.size() - 1);
strings.remove(0);
return strings.stream().map(String::trim).collect(Collectors.joining());
}
}

View File

@ -0,0 +1,65 @@
package dev.vality.wachter.config;
import org.keycloak.adapters.springsecurity.KeycloakSecurityComponents;
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.authentication.session.NullAuthenticatedSessionStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
@EnableWebSecurity
@ComponentScan(
basePackageClasses = KeycloakSecurityComponents.class,
excludeFilters = @ComponentScan.Filter(
type = FilterType.REGEX,
pattern = "org.keycloak.adapters.springsecurity.management.HttpSessionManager"))
@EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true)
@ConditionalOnProperty(value = "auth.enabled", havingValue = "true")
public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter {
@Bean
@Override
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
return new NullAuthenticatedSessionStrategy();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
http.cors().and()
.csrf().disable()
.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.antMatchers(HttpMethod.GET, "/**/health").permitAll()
.anyRequest().authenticated();
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(keycloakAuthenticationProvider());
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.applyPermitDefaultValues();
configuration.addAllowedMethod(HttpMethod.PUT);
configuration.addAllowedMethod(HttpMethod.DELETE);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View File

@ -0,0 +1,18 @@
package dev.vality.wachter.config;
import org.apache.thrift.protocol.TBinaryProtocol;
import org.apache.thrift.protocol.TProtocolFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ThriftGatewayConfiguration {
@Bean
@ConditionalOnMissingBean(TProtocolFactory.class)
TProtocolFactory thriftProtocolFactory() {
return new TBinaryProtocol.Factory();
}
}

View File

@ -0,0 +1,103 @@
package dev.vality.wachter.config;
import dev.vality.wachter.utils.DeadlineUtil;
import dev.vality.woody.api.flow.WFlow;
import dev.vality.woody.api.trace.context.metadata.user.UserIdentityEmailExtensionKit;
import dev.vality.woody.api.trace.context.metadata.user.UserIdentityIdExtensionKit;
import dev.vality.woody.api.trace.context.metadata.user.UserIdentityRealmExtensionKit;
import dev.vality.woody.api.trace.context.metadata.user.UserIdentityUsernameExtensionKit;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken;
import org.keycloak.representations.AccessToken;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.security.Principal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static dev.vality.woody.api.trace.ContextUtils.setCustomMetadataValue;
import static dev.vality.woody.api.trace.ContextUtils.setDeadline;
@Configuration
@SuppressWarnings({"ParameterName", "LocalVariableName"})
public class WebConfig {
@Bean
public FilterRegistrationBean woodyFilter() {
WFlow woodyFlow = new WFlow();
Filter filter = new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
woodyFlow.createServiceFork(
() -> {
try {
if (request.getUserPrincipal() != null) {
addWoodyContext(request.getUserPrincipal());
}
setWoodyDeadline(request);
filterChain.doFilter(request, response);
} catch (IOException | ServletException e) {
sneakyThrow(e);
}
}
)
.run();
}
private <E extends Throwable, T> T sneakyThrow(Throwable t) throws E {
throw (E) t;
}
};
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.setFilter(filter);
filterRegistrationBean.setOrder(-50);
filterRegistrationBean.setName("woodyFilter");
filterRegistrationBean.addUrlPatterns("*");
return filterRegistrationBean;
}
private void addWoodyContext(Principal principal) {
KeycloakSecurityContext keycloakSecurityContext =
((KeycloakAuthenticationToken) principal).getAccount().getKeycloakSecurityContext();
AccessToken accessToken = keycloakSecurityContext.getToken();
setCustomMetadataValue(UserIdentityIdExtensionKit.KEY, accessToken.getSubject());
setCustomMetadataValue(UserIdentityUsernameExtensionKit.KEY, accessToken.getPreferredUsername());
setCustomMetadataValue(UserIdentityEmailExtensionKit.KEY, accessToken.getEmail());
setCustomMetadataValue(UserIdentityRealmExtensionKit.KEY, keycloakSecurityContext.getRealm());
}
private void setWoodyDeadline(HttpServletRequest request) {
String xRequestDeadline = request.getHeader("X-Request-Deadline");
String xRequestId = request.getHeader("X-Request-ID");
if (xRequestDeadline != null) {
setDeadline(getInstant(xRequestDeadline, xRequestId));
}
}
private Instant getInstant(String xRequestDeadline, String xRequestId) {
if (DeadlineUtil.containsRelativeValues(xRequestDeadline, xRequestId)) {
return Instant.now()
.plus(DeadlineUtil.extractMilliseconds(xRequestDeadline, xRequestId), ChronoUnit.MILLIS)
.plus(DeadlineUtil.extractSeconds(xRequestDeadline, xRequestId), ChronoUnit.MILLIS)
.plus(DeadlineUtil.extractMinutes(xRequestDeadline, xRequestId), ChronoUnit.MILLIS);
} else {
return Instant.parse(xRequestDeadline);
}
}
}

View File

@ -0,0 +1,26 @@
package dev.vality.wachter.config.properties;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import javax.validation.constraints.NotEmpty;
@Getter
@Setter
@Component
@Validated
@ConfigurationProperties(prefix = "bouncer")
public class BouncerProperties {
@NotEmpty
private String deploymentId;
@NotEmpty
private String authMethod;
@NotEmpty
private String realm;
@NotEmpty
private String ruleSetId;
}

View File

@ -0,0 +1,34 @@
package dev.vality.wachter.config.properties;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import javax.validation.constraints.NotEmpty;
@Getter
@Setter
@Component
@Validated
@ConfigurationProperties(prefix = "keycloak")
public class KeycloakProperties {
@NotEmpty
private String realm;
@NotEmpty
private String authServerUrl;
@NotEmpty
private String resource;
private Integer notBefore;
@NotEmpty
private String sslRequired;
private String realmPublicKey;
private String realmPublicKeyPath;
}

View File

@ -0,0 +1,30 @@
package dev.vality.wachter.config.properties;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import java.util.Map;
@Getter
@Setter
@Component
@Validated
@ConfigurationProperties(prefix = "wachter")
public class WachterProperties {
private String serviceHeader;
private Map<String, Services> services;
@Getter
@Setter
public static class Services {
private String name;
private String url;
}
}

View File

@ -0,0 +1,60 @@
package dev.vality.wachter.controller;
import dev.vality.wachter.exeptions.AuthorizationException;
import dev.vality.wachter.exeptions.WachterException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.HttpClientErrorException;
import java.net.http.HttpTimeoutException;
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class ErrorControllerAdvice {
@ExceptionHandler({WachterException.class})
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Object handleBadRequestException(WachterException e) {
log.warn("<- Res [400]: Not valid request", e);
return e.getMessage();
}
@ExceptionHandler({AccessDeniedException.class})
@ResponseStatus(HttpStatus.FORBIDDEN)
public void handleAccessDeniedException(AccessDeniedException e) {
log.warn("<- Res [403]: Request denied access", e);
}
@ExceptionHandler({AuthorizationException.class})
@ResponseStatus(HttpStatus.FORBIDDEN)
public void handleAccessDeniedException(AuthorizationException e) {
log.warn("<- Res [403]: Request denied access", e);
}
@ExceptionHandler(HttpClientErrorException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public void handleHttpClientErrorException(HttpClientErrorException e) {
log.error("<- Res [500]: Error with using inner http client, code={}, body={}",
e.getStatusCode(), e.getResponseBodyAsString(), e);
}
@ExceptionHandler(HttpTimeoutException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public void handleHttpTimeoutException(HttpTimeoutException e) {
log.error("<- Res [500]: Timeout with using inner http client", e);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public void handleException(Exception e) {
log.error("<- Res [500]: Unrecognized inner error", e);
}
}

View File

@ -0,0 +1,26 @@
package dev.vality.wachter.controller;
import dev.vality.wachter.service.WachterService;
import lombok.RequiredArgsConstructor;
import org.apache.thrift.TException;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@RestController
@RequiredArgsConstructor
@RequestMapping("")
public class WachterController {
private final WachterService wachterService;
@PostMapping("/wachter")
public byte[] getRequest(HttpServletRequest request) throws IOException, TException {
return wachterService.process(request);
}
}

View File

@ -0,0 +1,8 @@
package dev.vality.wachter.exeptions;
public class AuthorizationException extends WachterException {
public AuthorizationException(String s) {
super(s);
}
}

View File

@ -0,0 +1,12 @@
package dev.vality.wachter.exeptions;
public class BouncerException extends WachterException {
public BouncerException(String s) {
super(s);
}
public BouncerException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,8 @@
package dev.vality.wachter.exeptions;
public class DeadlineException extends WachterException {
public DeadlineException(String message) {
super(message);
}
}

View File

@ -0,0 +1,8 @@
package dev.vality.wachter.exeptions;
public class OrgManagerException extends WachterException {
public OrgManagerException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,12 @@
package dev.vality.wachter.exeptions;
public class WachterException extends RuntimeException {
public WachterException(String message) {
super(message);
}
public WachterException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,32 @@
package dev.vality.wachter.mapper;
import dev.vality.wachter.config.properties.WachterProperties;
import dev.vality.wachter.exeptions.WachterException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
@Component
@RequiredArgsConstructor
public class ServiceMapper {
private final WachterProperties wachterProperties;
public WachterProperties.Services getService(HttpServletRequest request) {
if (request.getHeader(wachterProperties.getServiceHeader()) == null) {
throw new WachterException(
String.format("Header \"%s\" must be set", wachterProperties.getServiceHeader()));
}
WachterProperties.Services service = wachterProperties.getServices()
.get(request.getHeader(wachterProperties.getServiceHeader()));
if (service == null) {
throw new WachterException(
String.format("Service \"%s\" not found in configuration",
request.getHeader(wachterProperties.getServiceHeader())));
}
return service;
}
}

View File

@ -0,0 +1,19 @@
package dev.vality.wachter.security;
import dev.vality.wachter.config.properties.WachterProperties;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class AccessData {
private final String operationId;
private final String partyId;
private final long tokenExpirationSec;
private final String tokenId;
private final String userId;
private final String userEmail;
private final WachterProperties.Services service;
}

View File

@ -0,0 +1,49 @@
package dev.vality.wachter.security;
import dev.vality.wachter.exeptions.AuthorizationException;
import dev.vality.wachter.exeptions.BouncerException;
import dev.vality.wachter.service.BouncerService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Slf4j
@RequiredArgsConstructor
@Service
public class AccessService {
private final BouncerService bouncerService;
@Value("${bouncer.auth.enabled}")
private boolean authEnabled;
public void checkUserAccess(AccessData accessData) {
log.info("Check the {} rights to perform the operation {} in service {}",
accessData.getUserEmail(),
accessData.getOperationId(),
accessData.getService().getName());
var resolution = bouncerService.getResolution(accessData);
switch (resolution.getSetField()) {
case FORBIDDEN, RESTRICTED -> {
if (authEnabled) {
throw new AuthorizationException(
String.format("No rights for %s to perform %s in service %s",
accessData.getUserEmail(),
accessData.getOperationId(),
accessData.getService().getName()));
} else {
log.warn("No rights for {} to perform {} in service {}",
accessData.getUserEmail(),
accessData.getOperationId(),
accessData.getService().getName());
}
}
case ALLOWED -> log.info("Rights for {} to perform {} in service {} are allowed",
accessData.getUserEmail(),
accessData.getOperationId(),
accessData.getService().getName());
default -> throw new BouncerException(String.format("Resolution %s cannot be processed", resolution));
}
}
}

View File

@ -0,0 +1,68 @@
package dev.vality.wachter.security;
import dev.vality.bouncer.base.Entity;
import dev.vality.bouncer.context.v1.*;
import dev.vality.bouncer.ctx.ContextFragmentType;
import dev.vality.bouncer.decisions.Context;
import dev.vality.wachter.config.properties.BouncerProperties;
import dev.vality.wachter.service.KeycloakService;
import dev.vality.wachter.service.OrgManagerService;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.apache.thrift.TSerializer;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
@RequiredArgsConstructor
@Component
public class BouncerContextFactory {
private final BouncerProperties bouncerProperties;
private final OrgManagerService orgManagerService;
private final KeycloakService keycloakService;
@SneakyThrows
public Context buildContext(AccessData accessData) {
var contextFragment = buildContextFragment(accessData);
var serializer = new TSerializer();
var fragment = new dev.vality.bouncer.ctx.ContextFragment()
.setType(ContextFragmentType.v1_thrift_binary)
.setContent(serializer.serialize(contextFragment));
var userFragment = orgManagerService.getUserAuthContext(
keycloakService.getAccessToken().getSubject());
var context = new Context();
context.putToFragments(accessData.getService().getName(), fragment);
context.putToFragments("user", userFragment);
return context;
}
private ContextFragment buildContextFragment(AccessData accessData) {
var env = buildEnvironment();
return new ContextFragment()
.setAuth(buildAuth(accessData))
.setEnv(env);
}
private Auth buildAuth(AccessData accessData) {
var auth = new Auth();
Set<AuthScope> authScopeSet = new HashSet<>();
authScopeSet.add(new AuthScope()
.setParty(new Entity().setId(accessData.getPartyId())));
return auth.setToken(new Token().setId(accessData.getTokenId()))
.setMethod(bouncerProperties.getAuthMethod())
.setExpiration(Instant.ofEpochSecond(accessData.getTokenExpirationSec()).toString())
.setScope(authScopeSet);
}
private Environment buildEnvironment() {
var deployment = new Deployment()
.setId(bouncerProperties.getDeploymentId());
return new Environment()
.setDeployment(deployment)
.setNow(Instant.now().toString());
}
}

View File

@ -0,0 +1,37 @@
package dev.vality.wachter.service;
import dev.vality.bouncer.decisions.ArbiterSrv;
import dev.vality.bouncer.decisions.Resolution;
import dev.vality.wachter.config.properties.BouncerProperties;
import dev.vality.wachter.exeptions.BouncerException;
import dev.vality.wachter.security.AccessData;
import dev.vality.wachter.security.BouncerContextFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.thrift.TException;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class BouncerService {
private final BouncerProperties bouncerProperties;
private final BouncerContextFactory bouncerContextFactory;
private final ArbiterSrv.Iface bouncerClient;
public Resolution getResolution(AccessData accessData) {
log.debug("Check access with bouncer context");
var context = bouncerContextFactory.buildContext(accessData);
log.debug("Built thrift context: {}", context);
try {
var judge = bouncerClient.judge(bouncerProperties.getRuleSetId(), context);
log.debug("Have judge: {}", judge);
var resolution = judge.getResolution();
log.debug("Resolution: {}", resolution);
return resolution;
} catch (TException e) {
throw new BouncerException("Error while call bouncer", e);
}
}
}

View File

@ -0,0 +1,22 @@
package dev.vality.wachter.service;
import org.keycloak.KeycloakPrincipal;
import org.keycloak.representations.AccessToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@Service
public class KeycloakService {
public String getPartyId() {
return ((KeycloakPrincipal) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getName();
}
public AccessToken getAccessToken() {
KeycloakPrincipal keycloakPrincipal = (KeycloakPrincipal) SecurityContextHolder.getContext()
.getAuthentication()
.getPrincipal();
return keycloakPrincipal.getKeycloakSecurityContext().getToken();
}
}

View File

@ -0,0 +1,24 @@
package dev.vality.wachter.service;
import dev.vality.bouncer.ctx.ContextFragment;
import dev.vality.orgmanagement.AuthContextProviderSrv;
import dev.vality.wachter.exeptions.OrgManagerException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class OrgManagerService {
private final AuthContextProviderSrv.Iface orgManagerClient;
public ContextFragment getUserAuthContext(String userId) {
try {
return orgManagerClient.getUserContext(userId);
} catch (Exception e) {
throw new OrgManagerException(
String.format("Can't get user auth context on orgManager call: userId = %s", userId),
e);
}
}
}

View File

@ -0,0 +1,52 @@
package dev.vality.wachter.service;
import dev.vality.wachter.client.WachterClient;
import dev.vality.wachter.security.AccessData;
import dev.vality.wachter.security.AccessService;
import dev.vality.wachter.mapper.ServiceMapper;
import lombok.RequiredArgsConstructor;
import org.apache.thrift.TException;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import static dev.vality.wachter.utils.MethodNameReader.getMethodName;
@RequiredArgsConstructor
@Service
public class WachterService {
private final KeycloakService keycloakService;
private final AccessService accessService;
private final WachterClient wachterClient;
private final ServiceMapper serviceMapper;
public byte[] process(HttpServletRequest request) throws IOException, TException {
byte[] contentData = getContentData(request);
var methodName = getMethodName(contentData);
var partyID = keycloakService.getPartyId();
var token = keycloakService.getAccessToken();
var service = serviceMapper.getService(request);
accessService.checkUserAccess(AccessData.builder()
.operationId(methodName)
.partyId(partyID)
.tokenExpirationSec(token.getExp())
.tokenId(token.getId())
.userId(token.getSubject())
.userEmail(token.getEmail())
.service(service)
.build());
return wachterClient.send(request, contentData, service);
}
private byte[] getContentData(HttpServletRequest request) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
IOUtils.copy(request.getInputStream(), baos);
return baos.toByteArray();
}
}

View File

@ -0,0 +1,120 @@
package dev.vality.wachter.utils;
import dev.vality.wachter.exeptions.DeadlineException;
import lombok.experimental.UtilityClass;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@UtilityClass
@SuppressWarnings("ParameterName")
public class DeadlineUtil {
private static final String FLOATING_NUMBER_REGEXP = "[0-9]+([.][0-9]+)?";
private static final String MIN_REGEXP = "(?!ms)[m]";
private static final String SEC_REGEXP = "[s]";
private static final String MILLISECOND_REGEXP = "[m][s]";
public static boolean containsRelativeValues(String xRequestDeadline, String xRequestId) {
return extractMinutes(xRequestDeadline, xRequestId) + extractSeconds(xRequestDeadline, xRequestId) +
extractMilliseconds(xRequestDeadline, xRequestId) > 0;
}
public static Long extractMinutes(String xRequestDeadline, String xRequestId) {
String format = "minutes";
checkNegativeValues(
xRequestDeadline,
xRequestId,
"([-]" + FLOATING_NUMBER_REGEXP + MIN_REGEXP + ")",
format);
Double minutes = extractValue(
xRequestDeadline,
"(" + FLOATING_NUMBER_REGEXP + MIN_REGEXP + ")",
xRequestId,
format);
return Optional.ofNullable(minutes).map(min -> min * 60000.0).map(Double::longValue).orElse(0L);
}
public static Long extractSeconds(String xRequestDeadline, String xRequestId) {
String format = "seconds";
checkNegativeValues(
xRequestDeadline,
xRequestId,
"([-]" + FLOATING_NUMBER_REGEXP + SEC_REGEXP + ")",
format);
Double seconds = extractValue(
xRequestDeadline,
"(" + FLOATING_NUMBER_REGEXP + SEC_REGEXP + ")",
xRequestId,
format);
return Optional.ofNullable(seconds).map(s -> s * 1000.0).map(Double::longValue).orElse(0L);
}
public static Long extractMilliseconds(String xRequestDeadline, String xRequestId) {
String format = "milliseconds";
checkNegativeValues(
xRequestDeadline,
xRequestId,
"([-]" + FLOATING_NUMBER_REGEXP + MILLISECOND_REGEXP + ")",
format);
Double milliseconds = extractValue(
xRequestDeadline,
"(" + FLOATING_NUMBER_REGEXP + MILLISECOND_REGEXP + ")",
xRequestId,
format);
if (milliseconds != null && Math.ceil(milliseconds % 1) > 0) {
throw new DeadlineException(
String.format("Deadline 'milliseconds' parameter can have only integer value, xRequestId=%s ",
xRequestId));
}
return Optional.ofNullable(milliseconds).map(Double::longValue).orElse(0L);
}
private static void checkNegativeValues(String xRequestDeadline, String xRequestId, String regex, String format) {
if (!match(regex, xRequestDeadline).isEmpty()) {
throw new DeadlineException(
String.format("Deadline '%s' parameter has negative value, xRequestId=%s ", format, xRequestId));
}
}
private static Double extractValue(String xRequestDeadline, String formatRegex, String xRequestId, String format) {
String numberRegex = "(" + FLOATING_NUMBER_REGEXP + ")";
List<String> doubles = new ArrayList<>();
for (String string : match(formatRegex, xRequestDeadline)) {
doubles.addAll(match(numberRegex, string));
}
if (doubles.size() > 1) {
throw new DeadlineException(
String.format("Deadline '%s' parameter has a few relative value, xRequestId=%s ", format,
xRequestId));
}
if (doubles.isEmpty()) {
return null;
}
return Double.valueOf(doubles.get(0));
}
private static List<String> match(String regex, String data) {
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(data);
List<String> strings = new ArrayList<>();
while (matcher.find()) {
strings.add(matcher.group());
}
return strings;
}
}

View File

@ -0,0 +1,23 @@
package dev.vality.wachter.utils;
import org.apache.thrift.TException;
import org.apache.thrift.protocol.TBinaryProtocol;
import org.apache.thrift.protocol.TMessage;
import org.apache.thrift.protocol.TProtocol;
import org.apache.thrift.transport.TMemoryInputTransport;
import org.apache.thrift.transport.TTransportException;
public class MethodNameReader {
public static String getMethodName(byte[] thriftBody) throws TException {
TProtocol protocol = createProtocol(thriftBody);
TMessage message = protocol.readMessageBegin();
protocol.readMessageEnd();
return message.name;
}
private static TProtocol createProtocol(byte[] thriftBody) throws TTransportException {
return new TBinaryProtocol.Factory().getProtocol(new TMemoryInputTransport(thriftBody));
}
}

View File

@ -0,0 +1,101 @@
server:
port: '@server.port@'
management:
security:
flag: false
server:
port: '@management.port@'
metrics:
tags:
application: '@project.name@'
export:
prometheus:
enabled: true
endpoint:
health:
show-details: always
metrics:
enabled: true
prometheus:
enabled: true
endpoints:
web:
exposure:
include: health,info,prometheus
spring:
application:
name: '@project.name@'
output:
ansi:
enabled: always
info:
version: '@project.version@'
stage: dev
wachter:
serviceHeader: Service
services:
messages:
name: messages
url: http://localhost:8097/v1/messages
automaton:
name: automaton
url: http://localhost:8022/v1/automaton
repairer:
name: repairer
url: http://localhost:8022/v1/repair/withdrawal/session
claimManagement:
name: claimManagement
url: http://localhost:8097/v1/cm
fistfulAdmin:
name: fistfulAdmin
url: http://localhost:8022/v1/admin
fistfulStatistics:
name: fistfulStatistics
url: http://localhost:8022/fistful/stat
fileStorage:
name: fileStorage
url: http://localhost:8022/file_storage
deanonimus:
name: deanonimus
url: http://localhost:8022/deanonimus
merchantStatistics:
name: merchantStatistics
url: http://localhost:8022/stat
paymentProcessing:
name: paymentProcessing
url: http://localhost:8022/v1/processing/invoicing
domain:
name: domain
url: http://localhost:8022/v1/domain/repository
http-client:
connectTimeout: 10000
connectionRequestTimeout: 10000
socketTimeout: 10000
orgManager:
url: http://localhost:8022/org/v1/auth-context
networkTimeout: 5000
bouncer:
url: http://localhost:8022/v1/arbiter
networkTimeout: 10000
deployment-id: production
auth-method: SessionToken
realm: external
rule-set-id: change_it
auth:
enabled: true
keycloak:
realm: internal
auth-server-url: http://keycloak:8080/auth/
resource: koffing
not-before: 0
ssl-required: none
realm-public-key:
auth.enabled: true

View File

@ -0,0 +1,45 @@
package dev.vality.wachter.auth;
import dev.vality.wachter.auth.utils.JwtTokenBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Properties;
@Configuration
public class JwtTokenTestConfiguration {
@Bean
public static PropertySourcesPlaceholderConfigurer properties(KeyPair keyPair)
throws NoSuchAlgorithmException, InvalidKeySpecException, IOException {
KeyFactory fact = KeyFactory.getInstance("RSA");
X509EncodedKeySpec spec = fact.getKeySpec(keyPair.getPublic(), X509EncodedKeySpec.class);
String publicKey = Base64.getEncoder().encodeToString(spec.getEncoded());
PropertySourcesPlaceholderConfigurer pspc = new PropertySourcesPlaceholderConfigurer();
Properties properties = new Properties();
properties.load(new ClassPathResource("application.yml").getInputStream());
properties.setProperty("keycloak.realm-public-key", publicKey);
pspc.setProperties(properties);
pspc.setLocalOverride(true);
return pspc;
}
@Bean
public JwtTokenBuilder jwtTokenBuilder(KeyPair keyPair) {
return new JwtTokenBuilder(keyPair.getPrivate());
}
@Bean
public KeyPair keyPair() throws GeneralSecurityException {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
return keyGen.generateKeyPair();
}
}

View File

@ -0,0 +1,18 @@
package dev.vality.wachter.auth;
import dev.vality.wachter.auth.utils.JwtTokenBuilder;
import dev.vality.wachter.auth.utils.KeycloakOpenIdStub;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class KeycloakOpenIdTestConfiguration {
@Bean
public KeycloakOpenIdStub keycloakOpenIdStub(@Value("${keycloak.auth-server-url}") String keycloakAuthServerUrl,
@Value("${keycloak.realm}") String keycloakRealm,
JwtTokenBuilder jwtTokenBuilder) {
return new KeycloakOpenIdStub(keycloakAuthServerUrl, keycloakRealm, jwtTokenBuilder);
}
}

View File

@ -0,0 +1,73 @@
package dev.vality.wachter.auth.utils;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.security.PrivateKey;
import java.time.Instant;
import java.util.UUID;
public class JwtTokenBuilder {
public static final String DEFAULT_USERNAME = "Darth Vader";
public static final String DEFAULT_EMAIL = "darkside-the-best@mail.com";
private final String userId;
private final String username;
private final String email;
private final PrivateKey privateKey;
public JwtTokenBuilder(PrivateKey privateKey) {
this(UUID.randomUUID().toString(), DEFAULT_USERNAME, DEFAULT_EMAIL, privateKey);
}
public JwtTokenBuilder(String userId, String username, String email, PrivateKey privateKey) {
this.userId = userId;
this.username = username;
this.email = email;
this.privateKey = privateKey;
}
public String generateJwtWithRoles(String issuer, String... roles) {
long iat = Instant.now().getEpochSecond();
long exp = iat + 60 * 10;
return generateJwtWithRoles(iat, exp, issuer, roles);
}
public String generateJwtWithRoles(long iat, long exp, String issuer, String... roles) {
String payload;
try {
payload = new JSONObject()
.put("jti", UUID.randomUUID().toString())
.put("exp", exp)
.put("nbf", "0")
.put("iat", iat)
.put("iss", issuer)
.put("aud", "private-api")
.put("sub", userId)
.put("typ", "Bearer")
.put("azp", "private-api")
.put("resource_access", new JSONObject()
.put("common-api", new JSONObject()
.put("roles", new JSONArray(roles))))
.put("preferred_username", username)
.put("email", email).toString();
} catch (JSONException e) {
throw new RuntimeException(e);
}
String jwt = Jwts.builder()
.setPayload(payload)
.signWith(SignatureAlgorithm.RS256, privateKey)
.compact();
return jwt;
}
}

View File

@ -0,0 +1,52 @@
package dev.vality.wachter.auth.utils;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
public class KeycloakOpenIdStub {
private final String keycloakRealm;
private final String issuer;
private final String openidConfig;
private final JwtTokenBuilder jwtTokenBuilder;
public KeycloakOpenIdStub(String keycloakAuthServerUrl, String keycloakRealm, JwtTokenBuilder jwtTokenBuilder) {
this.keycloakRealm = keycloakRealm;
this.jwtTokenBuilder = jwtTokenBuilder;
this.issuer = keycloakAuthServerUrl + "/realms/" + keycloakRealm;
this.openidConfig = "{\n" +
" \"issuer\": \"" + issuer + "\",\n" +
" \"authorization_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
"/protocol/openid-connect/auth\",\n" +
" \"token_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
"/protocol/openid-connect/token\",\n" +
" \"token_introspection_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
"/protocol/openid-connect/token/introspect\",\n" +
" \"userinfo_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
"/protocol/openid-connect/userinfo\",\n" +
" \"end_session_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
"/protocol/openid-connect/logout\",\n" +
" \"jwks_uri\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
"/protocol/openid-connect/certs\",\n" +
" \"check_session_iframe\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
"/protocol/openid-connect/login-status-iframe.html\",\n" +
" \"registration_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
"/clients-registrations/openid-connect\",\n" +
" \"introspection_endpoint\": \"" + keycloakAuthServerUrl + "/realms/" + keycloakRealm +
"/protocol/openid-connect/token/introspect\"\n" +
"}";
}
public void givenStub() {
stubFor(get(urlEqualTo(String.format("/auth/realms/%s/.well-known/openid-configuration", keycloakRealm)))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody(openidConfig)
)
);
}
public String generateJwt(String... roles) {
return jwtTokenBuilder.generateJwtWithRoles(issuer, roles);
}
}

View File

@ -0,0 +1,35 @@
package dev.vality.wachter.config;
import dev.vality.wachter.WachterApplication;
import dev.vality.wachter.auth.utils.KeycloakOpenIdStub;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
classes = {WachterApplication.class},
properties = {
"wiremock.server.baseUrl=http://localhost:${wiremock.server.port}",
"keycloak.auth-server-url=${wiremock.server.baseUrl}/auth"})
@AutoConfigureMockMvc
@AutoConfigureWireMock(port = 0)
@ExtendWith(SpringExtension.class)
public abstract class AbstractKeycloakOpenIdAsWiremockConfig {
@Autowired
private KeycloakOpenIdStub keycloakOpenIdStub;
@BeforeAll
public static void setUp(@Autowired KeycloakOpenIdStub keycloakOpenIdStub) throws Exception {
keycloakOpenIdStub.givenStub();
}
protected String generateSimpleJwt() {
return keycloakOpenIdStub.generateJwt();
}
}

View File

@ -0,0 +1,140 @@
package dev.vality.wachter.controller;
import dev.vality.bouncer.decisions.ArbiterSrv;
import dev.vality.orgmanagement.AuthContextProviderSrv;
import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig;
import dev.vality.wachter.exeptions.AuthorizationException;
import dev.vality.wachter.exeptions.WachterException;
import dev.vality.wachter.testutil.TMessageUtil;
import lombok.SneakyThrows;
import org.apache.http.client.HttpClient;
import org.apache.thrift.protocol.TProtocolFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static dev.vality.wachter.testutil.ContextUtil.*;
import static java.util.UUID.randomUUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@TestPropertySource(properties = {"auth.enabled=true"})
class ErrorControllerTest extends AbstractKeycloakOpenIdAsWiremockConfig {
@MockBean
public AuthContextProviderSrv.Iface orgManagerClient;
@MockBean
public ArbiterSrv.Iface bouncerClient;
@MockBean
private HttpClient httpClient;
@Autowired
private MockMvc mvc;
@Autowired
private TProtocolFactory protocolFactory;
private AutoCloseable mocks;
private Object[] preparedMocks;
@BeforeEach
public void init() {
mocks = MockitoAnnotations.openMocks(this);
preparedMocks = new Object[]{httpClient, orgManagerClient, bouncerClient};
}
@AfterEach
public void clean() throws Exception {
verifyNoMoreInteractions(preparedMocks);
mocks.close();
}
@Test
@SneakyThrows
void requestJudgementRestricted() {
when(orgManagerClient.getUserContext(any())).thenReturn(createContextFragment());
when(bouncerClient.judge(any(), any())).thenReturn(createJudgementRestricted());
mvc.perform(post("/wachter")
.header("Authorization", "Bearer " + generateSimpleJwt())
.header("Service", "messages")
.header("X-Request-ID", randomUUID())
.header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString())
.content(TMessageUtil.createTMessage(protocolFactory)))
.andDo(print())
.andExpect(status().is4xxClientError())
.andExpect(result -> assertTrue(result.getResolvedException() instanceof AuthorizationException))
.andExpect(result -> assertEquals("No rights for darkside-the-best@mail.com to " +
"perform methodName in service messages",
result.getResolvedException().getMessage()));
verify(orgManagerClient, times(1)).getUserContext(any());
verify(bouncerClient, times(1)).judge(any(), any());
}
@Test
@SneakyThrows
void requestJudgementForbidden() {
when(orgManagerClient.getUserContext(any())).thenReturn(createContextFragment());
when(bouncerClient.judge(any(), any())).thenReturn(createJudgementForbidden());
mvc.perform(post("/wachter")
.header("Authorization", "Bearer " + generateSimpleJwt())
.header("Service", "messages")
.header("X-Request-ID", randomUUID())
.header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString())
.content(TMessageUtil.createTMessage(protocolFactory)))
.andDo(print())
.andExpect(status().is4xxClientError())
.andExpect(result -> assertTrue(result.getResolvedException() instanceof AuthorizationException))
.andExpect(result -> assertEquals("No rights for darkside-the-best@mail.com to " +
"perform methodName in service messages",
result.getResolvedException().getMessage()));
verify(orgManagerClient, times(1)).getUserContext(any());
verify(bouncerClient, times(1)).judge(any(), any());
}
@Test
@SneakyThrows
void requestWithoutServiceHeader() {
mvc.perform(post("/wachter")
.header("Authorization", "Bearer " + generateSimpleJwt())
.header("X-Request-ID", randomUUID())
.header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString())
.content(TMessageUtil.createTMessage(protocolFactory)))
.andDo(print())
.andExpect(status().is4xxClientError())
.andExpect(result -> assertTrue(result.getResolvedException() instanceof WachterException))
.andExpect(result -> assertEquals("Header \"Service\" must be set",
result.getResolvedException().getMessage()));
}
@Test
@SneakyThrows
void requestWithWrongServiceHeader() {
mvc.perform(post("/wachter")
.header("Authorization", "Bearer " + generateSimpleJwt())
.header("X-Request-ID", randomUUID())
.header("Service", "wrong")
.header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString())
.content(TMessageUtil.createTMessage(protocolFactory)))
.andDo(print())
.andExpect(status().is4xxClientError())
.andExpect(result -> assertTrue(result.getResolvedException() instanceof WachterException))
.andExpect(result -> assertEquals("Service \"wrong\" not found in configuration",
result.getResolvedException().getMessage()));
}
}

View File

@ -0,0 +1,86 @@
package dev.vality.wachter.controller;
import dev.vality.bouncer.decisions.ArbiterSrv;
import dev.vality.orgmanagement.AuthContextProviderSrv;
import dev.vality.wachter.config.AbstractKeycloakOpenIdAsWiremockConfig;
import dev.vality.wachter.testutil.TMessageUtil;
import lombok.SneakyThrows;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.entity.StringEntity;
import org.apache.thrift.protocol.TProtocolFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static dev.vality.wachter.testutil.ContextUtil.createContextFragment;
import static dev.vality.wachter.testutil.ContextUtil.createJudgementAllowed;
import static java.util.UUID.randomUUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class WachterControllerTest extends AbstractKeycloakOpenIdAsWiremockConfig {
@MockBean
public AuthContextProviderSrv.Iface orgManagerClient;
@MockBean
public ArbiterSrv.Iface bouncerClient;
@MockBean
private HttpClient httpClient;
@MockBean
private HttpResponse httpResponse;
@Autowired
private MockMvc mvc;
@Autowired
private TProtocolFactory protocolFactory;
private AutoCloseable mocks;
private Object[] preparedMocks;
@BeforeEach
public void init() {
mocks = MockitoAnnotations.openMocks(this);
preparedMocks = new Object[]{httpClient, orgManagerClient, bouncerClient};
}
@AfterEach
public void clean() throws Exception {
verifyNoMoreInteractions(preparedMocks);
mocks.close();
}
@Test
@SneakyThrows
void requestSuccess() {
when(orgManagerClient.getUserContext(any())).thenReturn(createContextFragment());
when(bouncerClient.judge(any(), any())).thenReturn(createJudgementAllowed());
when(httpResponse.getEntity()).thenReturn(new StringEntity(""));
when(httpClient.execute(any())).thenReturn(httpResponse);
mvc.perform(post("/wachter")
.header("Authorization", "Bearer " + generateSimpleJwt())
.header("Service", "messages")
.header("X-Request-ID", randomUUID())
.header("X-Request-Deadline", Instant.now().plus(1, ChronoUnit.DAYS).toString())
.content(TMessageUtil.createTMessage(protocolFactory)))
.andDo(print())
.andExpect(status().is2xxSuccessful());
verify(orgManagerClient, times(1)).getUserContext(any());
verify(bouncerClient, times(1)).judge(any(), any());
verify(httpClient, times(1)).execute(any());
}
}

View File

@ -0,0 +1,61 @@
package dev.vality.wachter.testutil;
import dev.vality.bouncer.ctx.ContextFragment;
import dev.vality.bouncer.decisions.*;
import dev.vality.geck.serializer.kit.mock.FieldHandler;
import dev.vality.geck.serializer.kit.mock.MockMode;
import dev.vality.geck.serializer.kit.mock.MockTBaseProcessor;
import dev.vality.geck.serializer.kit.tbase.TBaseHandler;
import lombok.SneakyThrows;
import lombok.experimental.UtilityClass;
import org.apache.thrift.TBase;
import org.apache.thrift.TSerializer;
import java.time.Instant;
import java.util.Map;
@UtilityClass
public class ContextUtil {
private static final MockTBaseProcessor mockRequiredTBaseProcessor;
static {
mockRequiredTBaseProcessor = new MockTBaseProcessor(MockMode.REQUIRED_ONLY, 15, 1);
Map.Entry<FieldHandler, String[]> timeFields = Map.entry(
structHandler -> structHandler.value(Instant.now().toString()),
new String[]{"conversation_id", "messages", "status", "user_id", "email", "fullname",
"held_until", "from_time", "to_time"}
);
mockRequiredTBaseProcessor.addFieldHandler(timeFields.getKey(), timeFields.getValue());
}
@SneakyThrows
public static <T extends TBase> T fillRequiredTBaseObject(T tbase, Class<T> type) {
return ContextUtil.mockRequiredTBaseProcessor.process(tbase, new TBaseHandler<>(type));
}
@SneakyThrows
public static ContextFragment createContextFragment() {
ContextFragment fragment = ContextUtil.fillRequiredTBaseObject(new ContextFragment(), ContextFragment.class);
fragment.setContent(new TSerializer().serialize(new dev.vality.bouncer.context.v1.ContextFragment()));
return fragment;
}
public static Judgement createJudgementAllowed() {
Resolution resolution = new Resolution();
resolution.setAllowed(new ResolutionAllowed());
return new Judgement().setResolution(resolution);
}
public static Judgement createJudgementRestricted() {
Resolution resolution = new Resolution();
resolution.setRestricted(new ResolutionRestricted());
return new Judgement().setResolution(resolution);
}
public static Judgement createJudgementForbidden() {
Resolution resolution = new Resolution();
resolution.setForbidden(new ResolutionForbidden());
return new Judgement().setResolution(resolution);
}
}

View File

@ -0,0 +1,42 @@
package dev.vality.wachter.testutil;
import org.apache.thrift.TException;
import org.apache.thrift.protocol.TMessage;
import org.apache.thrift.protocol.TMessageType;
import org.apache.thrift.protocol.TProtocol;
import org.apache.thrift.protocol.TProtocolFactory;
import org.apache.thrift.transport.TMemoryBuffer;
import org.apache.thrift.transport.TTransportException;
import java.util.Arrays;
public class TMessageUtil {
public static byte[] createTMessage(TProtocolFactory protocolFactory) throws TException {
TMemoryBufferWithLength memoryBuffer = new TMemoryBufferWithLength(1024);
TProtocol protocol = protocolFactory.getProtocol(memoryBuffer);
protocol.writeMessageBegin(new TMessage("methodName", TMessageType.REPLY, 0));
protocol.writeMessageEnd();
return Arrays.copyOf(memoryBuffer.getArray(), memoryBuffer.length());
}
public static class TMemoryBufferWithLength extends TMemoryBuffer {
private int actualLength = 0;
public TMemoryBufferWithLength(int size) throws TTransportException {
super(size);
}
@Override
public void write(byte[] buf, int off, int len) {
super.write(buf, off, len);
actualLength += len;
}
@Override
public int length() {
return actualLength;
}
}
}

View File

@ -0,0 +1,25 @@
package dev.vality.wachter.util;
import dev.vality.wachter.testutil.TMessageUtil;
import dev.vality.wachter.utils.MethodNameReader;
import org.apache.thrift.TException;
import org.apache.thrift.protocol.TProtocolFactory;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest
class MethodNameReaderTest {
@Autowired
private TProtocolFactory protocolFactory;
@Test
void readMethodName() throws TException {
byte[] message = TMessageUtil.createTMessage(protocolFactory);
assertEquals("methodName", MethodNameReader.getMethodName(message));
}
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<root level="warn">
<appender-ref ref="CONSOLE"/>
</root>
<logger name="dev.vality.woody" level="ALL"/>
</configuration>