Add Kafka, zookeeper. Add nginx.

This commit is contained in:
Miroslav Vasilev 2024-02-07 14:10:53 +02:00
parent 7720263b92
commit d7c3cbbc69
35 changed files with 536 additions and 142 deletions

View file

@ -0,0 +1,59 @@
server {
listen 80;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For &proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto &scheme;
proxy_set_header Host $http_host;
proxy_redirect off;
location / {
proxy_pass ${FRONTEND_URI};
location /login {
proxy_pass ${LOGIN_SERVICE_URI};
}
location /logout {
proxy_pass ${LOGIN_SERVICE_URI};
}
location /oauth2 {
proxy_pass ${LOGIN_SERVICE_URI};
}
location /refresh-token {
proxy_pass ${LOGIN_SERVICE_URI};
}
}
location /api {
rewrite ^/api/(.*) /$1 break;
proxy_pass ${CORE_API_URI};
proxy_set_header Authorization "Bearer $cookie_pefi_token";
proxy_pass_header Authorization;
location /api/enums/widget-types {
rewrite ^/api/(.*) /$1 break;
proxy_pass ${WIDGETS_API_URI};
}
location /api/enums/supported-conversions {
rewrite ^/api/(.*) /$1 break;
proxy_pass ${STATEMENTS_API_URI};
}
location /api/widgets {
rewrite ^/api/(.*) /$1 break;
proxy_pass ${WIDGETS_API_URI};
}
location /api/statements {
rewrite ^/api/(.*) /$1 break;
proxy_pass ${STATEMENTS_API_URI};
}
}
}

1
.idea/gradle.xml generated
View file

@ -14,6 +14,7 @@
<option value="$PROJECT_DIR$/pefi-common" />
<option value="$PROJECT_DIR$/pefi-core-api" />
<option value="$PROJECT_DIR$/pefi-frontend" />
<option value="$PROJECT_DIR$/pefi-login-service" />
<option value="$PROJECT_DIR$/pefi-statements-api" />
<option value="$PROJECT_DIR$/pefi-widgets-api" />
</set>

View file

@ -1,74 +1,87 @@
version: '3.4'
services:
api-gateway:
build: ./pefi-api-gateway
nginx:
image: nginx:stable-alpine
volumes:
- ./.docker/nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro
ports:
- '8080:8080'
- '8080:80'
environment:
PROFILE: development
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_ID}
AUTHENTIK_ISSUER_URL: https://auth.mvvasilev.dev/application/o/${AUTHENTIK_APP_NAME}/
AUTHENTIK_BACK_CHANNEL_LOGOUT_URL: https://auth.mvvasilev.dev/application/o/${AUTHENTIK_APP_NAME}/end-session/
GATEWAY_URI: http://localhost:8080
CORE_API_URI: http://core-api:8081
STATEMENTS_API_URI: http://statements-api:8081
WIDGETS_API_URI: http://widgets-api:8081
FRONTEND_URI: http://frontend:5173
REDIS_HOST: redis
REDIS_PORT: 6379
SSL_ENABLED: true
SSL_KEY_STORE_TYPE: PKCS12
SSL_KEY_STORE: classpath:keystore/local.p12
SSL_KEY_STORE_PASSWORD: asdf1234
SSL_KEY_ALIAS: local
LOGIN_SERVICE_URI: http://login-service:8081
depends_on:
- core-api
- statements-api
- widgets-api
- frontend
- login-service
frontend:
build: ./pefi-frontend
ports:
- '5173:5173'
login-service:
build: ./pefi-login-service
ports:
- '8084:8081'
environment:
SERVER_PORT: 8081
FRONTEND_URI: http://localhost:8080
AUTHENTIK_ISSUER_URI: ${AUTHENTIK_ISSUER_URL}
AUTHENTIK_BACK_CHANNEL_LOGOUT_URL: ${AUTHENTIK_BACK_CHANNEL_LOGOUT_URL}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
core-api:
build: ./pefi-core-api
ports:
- '8081:8081'
environment:
SERVER_PORT: 8081
PROFILE: 'development'
AUTHENTIK_ISSUER_URL: 'https://auth.mvvasilev.dev/application/o/${AUTHENTIK_APP_NAME}/'
AUTHENTIK_ISSUER_URI: ${AUTHENTIK_ISSUER_URL}
DATASOURCE_URL: jdbc:postgresql://database:5432/${POSTGRES_DB}
DATASOURCE_USER: ${POSTGRES_USER}
DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
KAFKA_SERVERS: 'kafka-broker:9092'
KAFKA_SERVERS: localhost:9092
depends_on:
- kafka1
- database
statements-api:
build: ./pefi-statements-api
ports:
- '8082:8081'
environment:
SERVER_PORT: 8081
PROFILE: 'development'
AUTHENTIK_ISSUER_URL: 'https://auth.mvvasilev.dev/application/o/${AUTHENTIK_APP_NAME}/'
AUTHENTIK_ISSUER_URI: ${AUTHENTIK_ISSUER_URL}
DATASOURCE_URL: jdbc:postgresql://database:5432/${POSTGRES_DB}
DATASOURCE_USER: ${POSTGRES_USER}
DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
KAFKA_SERVERS: 'kafka-broker:9092'
KAFKA_SERVERS: localhost:9092
depends_on:
- kafka1
- database
widgets-api:
build: ./pefi-widgets-api
ports:
- '8083:8081'
environment:
SERVER_PORT: 8081
PROFILE: 'development'
AUTHENTIK_ISSUER_URL: 'https://auth.mvvasilev.dev/application/o/${AUTHENTIK_APP_NAME}/'
AUTHENTIK_ISSUER_URI: ${AUTHENTIK_ISSUER_URL}
DATASOURCE_URL: jdbc:postgresql://database:5432/${POSTGRES_DB}
DATASOURCE_USER: ${POSTGRES_USER}
DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
redis:
image: redis/redis-stack:latest
ports:
- '6379:6379'
- '6380:8001'
depends_on:
- database
database:
image: postgres:16.1-alpine
@ -79,34 +92,38 @@ services:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
kafka-broker:
image: confluentinc/cp-kafka:7.5.3
hostname: broker
container_name: broker
depends_on:
- zookeeper
ports:
- "29092:29092"
- "9092:9092"
- "9101:9101"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181'
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:29092,PLAINTEXT_HOST://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_JMX_PORT: 9101
KAFKA_JMX_HOSTNAME: localhost
zookeeper:
image: confluentinc/cp-zookeeper:7.5.3
hostname: zookeeper
container_name: zookeeper
zoo1:
image: confluentinc/cp-zookeeper:7.3.2
hostname: zoo1
container_name: zoo1
ports:
- "2181:2181"
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
ZOOKEEPER_SERVER_ID: 1
ZOOKEEPER_SERVERS: zoo1:2888:3888
kafka1:
image: confluentinc/cp-kafka:7.3.2
hostname: kafka1
container_name: kafka1
ports:
- "9092:9092"
- "29092:29092"
- "9999:9999"
environment:
KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:19092,EXTERNAL://${DOCKER_HOST_IP:-127.0.0.1}:9092,DOCKER://host.docker.internal:29092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,DOCKER:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
KAFKA_ZOOKEEPER_CONNECT: "zoo1:2181"
KAFKA_BROKER_ID: 1
KAFKA_LOG4J_LOGGERS: "kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO"
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_JMX_PORT: 9999
KAFKA_JMX_HOSTNAME: ${DOCKER_HOST_IP:-127.0.0.1}
KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer
KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: "true"
depends_on:
- zoo1

View file

@ -10,6 +10,8 @@ repositories {
}
dependencies {
compileOnly 'jakarta.servlet:jakarta.servlet-api:6.0.0'
implementation 'jakarta.persistence:jakarta.persistence-api:3.1.0'
implementation 'org.springframework:spring-web:6.1.3'
implementation 'org.springframework.data:spring-data-jpa:3.2.0'

View file

@ -6,17 +6,29 @@ import org.apache.commons.lang3.exception.ExceptionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.filter.CommonsRequestLoggingFilter;
import java.util.List;
@Configuration
public class CommonControllerConfiguration {
@Bean
public CommonsRequestLoggingFilter requestLoggingFilter() {
CommonsRequestLoggingFilter loggingFilter = new CommonsRequestLoggingFilter();
loggingFilter.setIncludeClientInfo(true);
loggingFilter.setIncludeQueryString(true);
loggingFilter.setIncludePayload(true);
loggingFilter.setMaxPayloadLength(64000);
return loggingFilter;
}
@RestControllerAdvice(basePackages = {"dev.mvvasilev"})
public static class APIResponseAdvice {

View file

@ -1,59 +1,43 @@
package dev.mvvasilev.common.configuration;
import org.springframework.beans.factory.annotation.Value;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoders;
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.web.filter.CommonsRequestLoggingFilter;
import java.io.IOException;
@Configuration
@EnableTransactionManagement
public class CommonSecurityConfiguration {
@Value("${jwt.issuer-url}")
public String jwtIssuerUrl;
private static final String[] WHITELISTED_URLS = {
"/v3/api-docs/**",
"/swagger-ui/**",
"/v2/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/swagger-resources/**"
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, BearerTokenResolver bearerTokenResolver) throws Exception {
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.cors(c -> c.disable()) // won't be needing cors, as the API will be completely hidden behind an api gateway
.csrf(c -> c.disable())
.cors(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(c -> {
c.requestMatchers(WHITELISTED_URLS).permitAll();
c.requestMatchers(WHITELISTED_URLS).anonymous();
c.anyRequest().authenticated();
})
.oauth2ResourceServer(c -> {
c.jwt(Customizer.withDefaults());
c.bearerTokenResolver(bearerTokenResolver);
.oauth2ResourceServer(oauth2 -> {
oauth2.jwt(Customizer.withDefaults());
})
.exceptionHandling(e -> e.accessDeniedHandler((req, res, ex) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)))
.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return JwtDecoders.fromIssuerLocation(jwtIssuerUrl);
}
@Bean
public BearerTokenResolver bearerTokenResolver() {
DefaultBearerTokenResolver bearerTokenResolver = new DefaultBearerTokenResolver();
bearerTokenResolver.setBearerTokenHeaderName(HttpHeaders.AUTHORIZATION);
return bearerTokenResolver;
}
}

View file

@ -5,10 +5,15 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@SpringBootApplication(scanBasePackageClasses = { AuthorizationService.class })
@EnableJpaRepositories("dev.mvvasilev.finances.*")
@EnableWebMvc
@EntityScan("dev.mvvasilev.finances.*")
@EnableJpaRepositories("dev.mvvasilev.finances.*")
@SpringBootApplication(
scanBasePackageClasses = { AuthorizationService.class },
scanBasePackages = "dev.mvvasilev.finances.*"
)
public class PefiCoreAPI {
public static void main(String[] args) {

View file

@ -1,12 +1,15 @@
package dev.mvvasilev.finances.configuration;
import dev.mvvasilev.common.dto.KafkaReplaceProcessedTransactionsDTO;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.annotation.EnableKafka;
import org.springframework.kafka.annotation.EnableKafkaRetryTopic;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
@ -15,6 +18,7 @@ import org.springframework.kafka.support.serializer.JsonSerializer;
import java.util.Map;
@EnableKafka
@Configuration
public class KafkaConfiguration {
@ -25,12 +29,12 @@ public class KafkaConfiguration {
@Bean
public ConsumerFactory<String, KafkaReplaceProcessedTransactionsDTO> replaceTransactionsConsumerFactory() {
// ...
return new DefaultKafkaConsumerFactory<>(
Map.of(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapAddress,
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class,
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class
ConsumerConfig.GROUP_ID_CONFIG, "core-api",
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapAddress,
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringSerializer.class,
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonSerializer.class
),
new StringDeserializer(),
new JsonDeserializer<>(KafkaReplaceProcessedTransactionsDTO.class)

View file

@ -6,6 +6,7 @@ import dev.mvvasilev.common.web.APIResponseDTO;
import dev.mvvasilev.finances.dtos.CategorizationRuleDTO;
import dev.mvvasilev.finances.dtos.ProcessedTransactionFieldDTO;
import dev.mvvasilev.finances.enums.CategorizationRule;
import dev.mvvasilev.finances.enums.TimePeriod;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@ -18,6 +19,11 @@ import java.util.Collection;
@RequestMapping("/enums")
public class EnumsController extends AbstractRestController {
@GetMapping("/statistics-time-periods")
public ResponseEntity<APIResponseDTO<TimePeriod[]>> fetchTimePeriods() {
return ok(TimePeriod.values());
}
@GetMapping("/category-rules")
public ResponseEntity<APIResponseDTO<Collection<CategorizationRuleDTO>>> fetchCategorizationRules() {
return ok(

View file

@ -30,11 +30,6 @@ public class StatisticsController extends AbstractRestController {
this.statisticsService = statisticsService;
}
@GetMapping("/timePeriods")
public ResponseEntity<APIResponseDTO<TimePeriod[]>> fetchTimePeriods() {
return ok(TimePeriod.values());
}
@GetMapping("/totalSpendingByCategory")
@PreAuthorize("@authService.isOwner(#categoryId, T(dev.mvvasilev.finances.entity.TransactionCategory))")
public ResponseEntity<APIResponseDTO<SpendingByCategoriesDTO>> fetchSpendingByCategory(

View file

@ -19,7 +19,7 @@ public class TransactionsKafkaListener {
@KafkaListener(
topics = KafkaConfiguration.REPLACE_TRANSACTIONS_TOPIC,
containerFactory = "replaceTransactionsKafkaListenerContainerFactory"
groupId = "core-api"
)
public void replaceTransactionsListener(KafkaReplaceProcessedTransactionsDTO message) {
service.createOrReplaceProcessedTransactions(message.statementId(), message.userId(), message.transactions());

View file

@ -1,10 +1,9 @@
server.port=8081
debug=true
logging.level.org.springframework.boot.autoconfigure=ERROR
server.port=${SERVER_PORT}
spring.profiles.active=${PROFILE}
spring.security.oauth2.resourceserver.jwt.issuer-uri=${AUTHENTIK_ISSUER_URI}
# Database
spring.datasource.url=${DATASOURCE_URL}
spring.datasource.username=${DATASOURCE_USER}
@ -21,7 +20,4 @@ spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.table=core_schema_history
spring.flyway.baseline-version=0.9
spring.flyway.baseline-on-migrate=true
# Security
jwt.issuer-url=${AUTHENTIK_ISSUER_URL}
spring.flyway.baseline-on-migrate=true

View file

@ -24,10 +24,10 @@ export default function CategorizationRulesEditor({selectedCategory, onRuleBehav
toast.promise(
Promise.all([
utils.performRequest("/api/categories/rules")
utils.performRequest("/api/enums/category-rules")
.then(resp => resp.json())
.then(({result}) => setRuleTypes(result)),
utils.performRequest("/api/processed-transactions/fields")
utils.performRequest("/api/enums/processed-transaction-fields")
.then(resp => resp.json())
.then(({result}) => setFields(result))
]),

View file

@ -49,7 +49,7 @@ export default function WidgetEditModal(
}, [initialWidget]);
useEffect(() => {
utils.performRequest("/api/widgets/types")
utils.performRequest("/api/enums/widget-types")
.then(resp => resp.json())
.then(resp => setWidgetTypes(resp.result));
@ -57,7 +57,7 @@ export default function WidgetEditModal(
.then(resp => resp.json())
.then(resp => setCategories(resp.result));
utils.performRequest("/api/statistics/timePeriods")
utils.performRequest("/api/enums/statistics-time-periods")
.then(resp => resp.json())
.then(resp => setTimePeriods(resp.result));
}, []);

View file

@ -8,25 +8,48 @@ let LEV_FORMAT = new Intl.NumberFormat('bg-BG', {
let utils = {
performRequest: async (url, options) => {
let opts = options ?? { headers: {} };
return await fetch(url, {
let result = await fetch(url, {
...opts,
headers: {
...opts.headers,
'X-Requested-With': 'XMLHttpRequest'
}
}).then(resp => {
if (resp.status === 401) {
window.location.replace(`${window.location.origin}/oauth2/authorization/authentik`)
throw "Unauthorized, please login.";
}
if (!resp.ok) {
throw resp.status;
}
return resp;
});
if (result.ok) {
return result;
}
// If we are unauthorized, refresh the token, and try once more.
if (result.status === 401) {
let tokenResponse = await fetch("/refresh-token", {
method: "POST",
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
// If the token refresh failed, redirect to login
if (!tokenResponse.ok) {
window.location.replace(`${window.location.origin}/oauth2/authorization/authentik`);
}
// Try again
let secondAttempt = await fetch(url, {
...opts,
headers: {
...opts.headers,
'X-Requested-With': 'XMLHttpRequest'
}
});
// If our second attempt failed as well after refresh, redirect to login
if (!secondAttempt.ok && result.status === 401) {
window.location.replace(`${window.location.origin}/oauth2/authorization/authentik`);
}
}
// If the error wasn't unauthorized, just return the response
return result;
},
isSpinnerShown: () => {
return localStorage.getItem("SpinnerShowing") === "true";

View file

@ -0,0 +1,4 @@
AUTHENTIK_CLIENT_ID= authentik oauth2 client id
AUTHENTIK_CLIENT_SECRET= authentik oauth2 client secret
AUTHENTIK_ISSUER_URL= authentik issuer url ( dev: https://auth.mvvasilev.dev/application/o/personal-finances/ )
AUTHENTIK_BACK_CHANNEL_LOGOUT_URL= authentik back channel logout url ( dev: https://auth.mvvasilev.dev/application/o/personal-finances/end-session/ )

View file

@ -0,0 +1,7 @@
FROM eclipse-temurin:21-jdk-alpine
COPY ./build/libs/pefi-login-service-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8081
ENTRYPOINT exec java $JAVA_OPTS -jar /app.jar $ARGS

View file

@ -0,0 +1,24 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'dev.mvvasilev'
version = '0.0.1-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation platform('org.junit:junit-bom:5.9.1')
testImplementation 'org.junit.jupiter:junit-jupiter'
}
test {
useJUnitPlatform()
}

View file

@ -0,0 +1 @@
rootProject.name = 'pefi-login-service'

View file

@ -0,0 +1,11 @@
package dev.mvvasilev;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class PefiLoginService {
public static void main(String[] args) {
SpringApplication.run(PefiLoginService.class, args);
}
}

View file

@ -0,0 +1,58 @@
package dev.mvvasilev.configuration;
import dev.mvvasilev.service.TokenRefreshService;
import dev.mvvasilev.utils.CookieUtils;
import jakarta.servlet.http.Cookie;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Value("${auth.success.redirect}")
private String redirect;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, OAuth2AuthorizedClientRepository repository) throws Exception {
return http
.authorizeHttpRequests(authorize -> {
authorize.requestMatchers(HttpMethod.POST, "/refresh-token").permitAll();
authorize.anyRequest().authenticated();
})
.oauth2Login(l -> l.successHandler((req, res, auth) -> {
OAuth2AuthenticationToken oauth = (OAuth2AuthenticationToken) auth;
OAuth2AuthorizedClient authorizedClient = repository.loadAuthorizedClient(
oauth.getAuthorizedClientRegistrationId(),
auth,
req
);
res.addCookie(
CookieUtils.createAccessTokenCookie(authorizedClient.getAccessToken().getTokenValue())
);
if (authorizedClient.getRefreshToken() != null) {
res.addCookie(
CookieUtils.createRefreshTokenCookie(authorizedClient.getRefreshToken().getTokenValue())
);
}
res.setStatus(HttpStatus.TEMPORARY_REDIRECT.value());
res.addHeader("Location", redirect);
}))
.build();
}
}

View file

@ -0,0 +1,55 @@
package dev.mvvasilev.controller;
import dev.mvvasilev.configuration.SecurityConfiguration;
import dev.mvvasilev.dto.TokenDTO;
import dev.mvvasilev.exception.PefiLoginServiceException;
import dev.mvvasilev.service.TokenRefreshService;
import dev.mvvasilev.utils.CookieUtils;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.client.OAuth2AuthorizationContext;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.util.List;
@RestController
public class RefreshController {
private final TokenRefreshService tokenRefreshService;
public RefreshController(TokenRefreshService tokenRefreshService) {
this.tokenRefreshService = tokenRefreshService;
}
@PostMapping("/refresh-token")
public ResponseEntity<Void> getOidcUserPrincipal(
HttpServletResponse response,
@CookieValue(CookieUtils.REFRESH_TOKEN_NAME) String refreshToken
) {
try {
var token = tokenRefreshService.fetchNewTokens(refreshToken);
response.addCookie(CookieUtils.createAccessTokenCookie(token.accessToken()));
response.addCookie(CookieUtils.createRefreshTokenCookie(token.refreshToken()));
return ResponseEntity.ok().build();
} catch (PefiLoginServiceException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
}

View file

@ -0,0 +1,17 @@
package dev.mvvasilev.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
public record TokenDTO(
@JsonProperty("access_token")
String accessToken,
@JsonProperty("refresh_token")
String refreshToken,
@JsonProperty("id_token")
String idToken,
@JsonProperty("token_type")
String tokenType,
@JsonProperty("expires_in")
int expiredIn
) {
}

View file

@ -0,0 +1,7 @@
package dev.mvvasilev.exception;
public class PefiLoginServiceException extends RuntimeException {
public PefiLoginServiceException(String message) {
super(message);
}
}

View file

@ -0,0 +1,51 @@
package dev.mvvasilev.service;
import dev.mvvasilev.dto.TokenDTO;
import dev.mvvasilev.exception.PefiLoginServiceException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.util.List;
@Service
public class TokenRefreshService {
private final ClientRegistrationRepository clientRegistrationRepository;
@Autowired
public TokenRefreshService(ClientRegistrationRepository clientRegistrationRepository) {
this.clientRegistrationRepository = clientRegistrationRepository;
}
public TokenDTO fetchNewTokens(String refreshToken) {
var client = clientRegistrationRepository.findByRegistrationId("authentik");
var template = new RestTemplate();
var requestBody = new LinkedMultiValueMap<String, String>();
requestBody.put("grant_type", List.of("refresh_token"));
requestBody.put("client_id", List.of(client.getClientId()));
requestBody.put("client_secret", List.of(client.getClientSecret()));
requestBody.put("refresh_token", List.of(refreshToken));
var requestHeaders = new LinkedMultiValueMap<String, String>();
requestHeaders.put("Content-Type", List.of("application/x-www-form-urlencoded"));
var request = new HttpEntity<>(requestBody, requestHeaders);
var tokenResponse = template.postForEntity(client.getProviderDetails().getTokenUri(), request, TokenDTO.class);
if (!HttpStatus.OK.isSameCodeAs(tokenResponse.getStatusCode())) {
throw new PefiLoginServiceException("Token refresh failure");
}
return tokenResponse.getBody();
}
}

View file

@ -0,0 +1,27 @@
package dev.mvvasilev.utils;
import jakarta.servlet.http.Cookie;
public class CookieUtils {
public static final String ACCESS_TOKEN_NAME = "pefi_token";
public static final String REFRESH_TOKEN_NAME = "pefi_refresh_token";
public static Cookie createAccessTokenCookie(String value) {
var accessTokenCookie = new Cookie(ACCESS_TOKEN_NAME, value);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setPath("/api");
return accessTokenCookie;
}
public static Cookie createRefreshTokenCookie(String value) {
var refreshTokenCookie = new Cookie(REFRESH_TOKEN_NAME, value);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setPath("/refresh-token");
return refreshTokenCookie;
}
}

View file

@ -0,0 +1,12 @@
server.port=${SERVER_PORT}
auth.success.redirect=${FRONTEND_URI}
spring.security.oauth2.client.registration.authentik.client-id=${AUTHENTIK_CLIENT_ID}
spring.security.oauth2.client.registration.authentik.client-secret=${AUTHENTIK_CLIENT_SECRET}
spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/authentik
spring.security.oauth2.client.registration.authentik.scope=openid
spring.security.oauth2.client.provider.authentik.issuer-uri=${AUTHENTIK_ISSUER_URI}
spring.security.oauth2.client.provider.authentik.back-channel-logout-url=${AUTHENTIK_BACK_CHANNEL_LOGOUT_URL}

View file

@ -5,10 +5,15 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@SpringBootApplication(scanBasePackageClasses = { AuthorizationService.class })
@EnableJpaRepositories("dev.mvvasilev.statements.*")
@EnableWebMvc
@EntityScan("dev.mvvasilev.statements.*")
@EnableJpaRepositories("dev.mvvasilev.statements.*")
@SpringBootApplication(
scanBasePackageClasses = { AuthorizationService.class },
scanBasePackages = "dev.mvvasilev.statements.*"
)
public class PefiStatementsAPI {
public static void main(String[] args) {
SpringApplication.run(PefiStatementsAPI.class, args);

View file

@ -1,10 +1,9 @@
server.port=8081
debug=true
logging.level.org.springframework.boot.autoconfigure=ERROR
server.port=${SERVER_PORT}
spring.profiles.active=${PROFILE}
spring.security.oauth2.resourceserver.jwt.issuer-uri=${AUTHENTIK_ISSUER_URI}
# Database
spring.datasource.url=${DATASOURCE_URL}
spring.datasource.username=${DATASOURCE_USER}
@ -21,7 +20,4 @@ spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.table=statements_schema_history
spring.flyway.baseline-version=0.9
spring.flyway.baseline-on-migrate=true
# Security
jwt.issuer-url=${AUTHENTIK_ISSUER_URL}
spring.flyway.baseline-on-migrate=true

View file

@ -0,0 +1,9 @@
PROFILE= production/development
AUTHENTIK_ISSUER_URL= auth server configuration url for fetching JWKs ( dev: https://auth.mvvasilev.dev/application/o/personal-finances/ )
KAFKA_SERVERS= comma-delimited list of kafka servers to connect to
DATASOURCE_URL= database jdbc url ( postgres only, example: jdbc:postgresql://localhost:5432/mydatabase )
DATASOURCE_USER= database user
DATASOURCE_PASSWORD= database password

View file

@ -17,6 +17,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
implementation 'org.flywaydb:flyway-core'
implementation 'org.apache.commons:commons-lang3:3.14.0'

View file

@ -5,10 +5,15 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@SpringBootApplication(scanBasePackageClasses = { AuthorizationService.class })
@EnableJpaRepositories("dev.mvvasilev.widgets.*")
@EnableWebMvc
@EntityScan("dev.mvvasilev.widgets.*")
@EnableJpaRepositories("dev.mvvasilev.widgets.*")
@SpringBootApplication(
scanBasePackageClasses = { AuthorizationService.class },
scanBasePackages = "dev.mvvasilev.widgets.*"
)
public class PefiWidgetsAPI {
public static void main(String[] args) {
SpringApplication.run(PefiWidgetsAPI.class, args);

View file

@ -4,9 +4,11 @@ import dev.mvvasilev.common.configuration.CommonSecurityConfiguration;
import dev.mvvasilev.common.configuration.CommonSwaggerConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration
@Import(CommonSecurityConfiguration.class)
@EnableTransactionManagement
public class SecurityConfiguration {
}

View file

@ -1,10 +1,9 @@
server.port=8081
debug=true
logging.level.org.springframework.boot.autoconfigure=ERROR
server.port=${SERVER_PORT}
spring.profiles.active=${PROFILE}
spring.security.oauth2.resourceserver.jwt.issuer-uri=${AUTHENTIK_ISSUER_URI}
# Database
spring.datasource.url=${DATASOURCE_URL}
spring.datasource.username=${DATASOURCE_USER}
@ -19,7 +18,4 @@ spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.table=widgets_schema_history
spring.flyway.baseline-version=0.9
spring.flyway.baseline-on-migrate=true
# Security
jwt.issuer-url=${AUTHENTIK_ISSUER_URL}
spring.flyway.baseline-on-migrate=true

View file

@ -6,4 +6,5 @@ include 'pefi-api-gateway'
include 'pefi-core-api'
include 'pefi-statements-api'
include 'pefi-widgets-api'
include 'pefi-login-service'