From 2afef923747839c76c3172ed5e8ce796304b69e0 Mon Sep 17 00:00:00 2001 From: FarukBraimo Date: Tue, 18 Jun 2024 16:00:37 +0200 Subject: [PATCH] - Feature: Securing endpoints. --- docker/docker-compose.yml | 11 +++ pom.xml | 23 +++--- .../falcon/config/security/CustomFilter.java | 73 +++++++++++++++++++ .../security/PasswordEncoderProvider.java | 12 +++ .../config/security/SecurityConfig.java | 38 ++++++++++ .../falcon/config/security/UserDetails.java | 11 +++ .../falcon/controller/AuthController.java | 29 ++++++++ .../falcon/controller/InsightController.java | 4 +- .../java/com/vodacom/falcon/model/User.java | 32 ++++++++ .../request/UserRegistrationRequest.java | 4 + .../model/response/ExchangeRateResponse.java | 2 +- .../model/response/InsightResponse.java | 1 + .../model/response/MetadataResponse.java | 17 +++++ .../OpenDailyWhetherResponse.java | 5 +- .../falcon/repository/UserRepository.java | 13 ++++ .../vodacom/falcon/service/AuthService.java | 34 +++++++++ .../falcon/service/ExchangeRateService.java | 6 +- .../falcon/service/InsightService.java | 26 ++++++- .../service/WeatherForecastService.java | 8 +- .../vodacom/falcon/util/FalconDefaults.java | 2 + src/main/resources/application.properties | 2 +- src/main/resources/application.yml | 24 ++++-- 22 files changed, 349 insertions(+), 28 deletions(-) create mode 100644 docker/docker-compose.yml create mode 100644 src/main/java/com/vodacom/falcon/config/security/CustomFilter.java create mode 100644 src/main/java/com/vodacom/falcon/config/security/PasswordEncoderProvider.java create mode 100644 src/main/java/com/vodacom/falcon/config/security/SecurityConfig.java create mode 100644 src/main/java/com/vodacom/falcon/config/security/UserDetails.java create mode 100644 src/main/java/com/vodacom/falcon/controller/AuthController.java create mode 100644 src/main/java/com/vodacom/falcon/model/User.java create mode 100644 src/main/java/com/vodacom/falcon/model/request/UserRegistrationRequest.java create mode 100644 src/main/java/com/vodacom/falcon/model/response/MetadataResponse.java create mode 100644 src/main/java/com/vodacom/falcon/repository/UserRepository.java create mode 100644 src/main/java/com/vodacom/falcon/service/AuthService.java diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..7c4d2fd --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,11 @@ +version: '3.8' +services: + db: + image: postgres + container_name: falcon_pg + environment: + POSTGRES_USER: admin + POSTGRES_PASSWORD: 123 + POSTGRES_DB: falcon + ports: + - "5432:5432" \ No newline at end of file diff --git a/pom.xml b/pom.xml index 2fa74a1..b85b9ed 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ com.vodacom falcon 0.0.1-SNAPSHOT - falcon + falcon-api Falcon: Travel Assistant API 17 @@ -19,22 +19,27 @@ - - - - + + org.springframework.boot + spring-boot-starter-data-jpa + - - - - + + org.springframework.boot + spring-boot-starter-security + org.springframework.boot spring-boot-starter-web + + org.postgresql + postgresql + runtime + dev.failsafe failsafe diff --git a/src/main/java/com/vodacom/falcon/config/security/CustomFilter.java b/src/main/java/com/vodacom/falcon/config/security/CustomFilter.java new file mode 100644 index 0000000..ce7f787 --- /dev/null +++ b/src/main/java/com/vodacom/falcon/config/security/CustomFilter.java @@ -0,0 +1,73 @@ +package com.vodacom.falcon.config.security; + +import com.vodacom.falcon.model.User; +import com.vodacom.falcon.repository.UserRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Base64; +import java.util.Objects; + +import static com.vodacom.falcon.util.FalconDefaults.AUTHORIZATION; +import static com.vodacom.falcon.util.FalconDefaults.BASIC; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomFilter extends OncePerRequestFilter { + private final UserRepository userRepository; + private final PasswordEncoderProvider encoderProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + log.info("Internal filter {}", request.getPathInfo()); + if (isBasicAuth(request)) { + String base64 = this.getHeader(request) + .replace(BASIC, ""); + String[] credentials = new String(Base64.getDecoder() + .decode(base64)) + .split(":"); + + String username = credentials[0]; + String pass = credentials[1]; + + User user = userRepository.findByUsername(username); + + if (Objects.isNull(user)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("User not found!"); + return; + } + + if (encoderProvider.passwordEncoder().matches(user.getPassword(), pass)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Wrong Password"); + return; + } + + Authentication authentication = new UsernamePasswordAuthenticationToken(user, null, null); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private boolean isBasicAuth(HttpServletRequest request) { + String header = getHeader(request); + return header != null && header.startsWith(BASIC); + } + + private String getHeader(HttpServletRequest request) { + return request.getHeader(AUTHORIZATION); + } +} diff --git a/src/main/java/com/vodacom/falcon/config/security/PasswordEncoderProvider.java b/src/main/java/com/vodacom/falcon/config/security/PasswordEncoderProvider.java new file mode 100644 index 0000000..0539d92 --- /dev/null +++ b/src/main/java/com/vodacom/falcon/config/security/PasswordEncoderProvider.java @@ -0,0 +1,12 @@ +package com.vodacom.falcon.config.security; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class PasswordEncoderProvider { + public PasswordEncoder passwordEncoder(){ + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/vodacom/falcon/config/security/SecurityConfig.java b/src/main/java/com/vodacom/falcon/config/security/SecurityConfig.java new file mode 100644 index 0000000..4ca5e20 --- /dev/null +++ b/src/main/java/com/vodacom/falcon/config/security/SecurityConfig.java @@ -0,0 +1,38 @@ +package com.vodacom.falcon.config.security; + + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +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.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import static jakarta.servlet.DispatcherType.ERROR; +import static jakarta.servlet.DispatcherType.FORWARD; + +@Configuration +@EnableMethodSecurity +@EnableWebSecurity +public class SecurityConfig { + @Autowired + private CustomFilter filter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authorize -> authorize + .dispatcherTypeMatchers(FORWARD, ERROR) + .permitAll() + .requestMatchers("falcon/auth/**", "falcon/insight/**") + .permitAll() + .anyRequest().denyAll() + ) + .addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} diff --git a/src/main/java/com/vodacom/falcon/config/security/UserDetails.java b/src/main/java/com/vodacom/falcon/config/security/UserDetails.java new file mode 100644 index 0000000..2bebf4e --- /dev/null +++ b/src/main/java/com/vodacom/falcon/config/security/UserDetails.java @@ -0,0 +1,11 @@ +package com.vodacom.falcon.config.security; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UserDetails { + private String username; + private String password; +} diff --git a/src/main/java/com/vodacom/falcon/controller/AuthController.java b/src/main/java/com/vodacom/falcon/controller/AuthController.java new file mode 100644 index 0000000..2ab577f --- /dev/null +++ b/src/main/java/com/vodacom/falcon/controller/AuthController.java @@ -0,0 +1,29 @@ +package com.vodacom.falcon.controller; + + +import com.vodacom.falcon.model.request.UserRegistrationRequest; +import com.vodacom.falcon.service.AuthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@CrossOrigin(value = "*") +@RequiredArgsConstructor +@Slf4j +@RequestMapping("falcon/auth") +public class AuthController { + private final AuthService service; + + @PostMapping("/signup") + public ResponseEntity signup(@RequestBody UserRegistrationRequest userRegistrationRequest) { + service.createUser(userRegistrationRequest); + return new ResponseEntity<>(HttpStatus.ACCEPTED); + } +} diff --git a/src/main/java/com/vodacom/falcon/controller/InsightController.java b/src/main/java/com/vodacom/falcon/controller/InsightController.java index 151a9da..7a8024e 100644 --- a/src/main/java/com/vodacom/falcon/controller/InsightController.java +++ b/src/main/java/com/vodacom/falcon/controller/InsightController.java @@ -8,6 +8,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -15,10 +16,11 @@ @CrossOrigin(value = "*") @RequiredArgsConstructor @Slf4j +@RequestMapping("falcon/insight") public class InsightController { private final InsightService falconInsightService; - @GetMapping("/insight") + @GetMapping() public ResponseEntity getInsight(@RequestParam("city") String city) { InsightResponse response = falconInsightService.getInsight(city); return new ResponseEntity<>(response, HttpStatus.OK); diff --git a/src/main/java/com/vodacom/falcon/model/User.java b/src/main/java/com/vodacom/falcon/model/User.java new file mode 100644 index 0000000..bca6b66 --- /dev/null +++ b/src/main/java/com/vodacom/falcon/model/User.java @@ -0,0 +1,32 @@ +package com.vodacom.falcon.model; + + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Setter +@Getter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Table(name = "falcon_user") +public class User { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private UUID id; + private String username; + private String password; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/vodacom/falcon/model/request/UserRegistrationRequest.java b/src/main/java/com/vodacom/falcon/model/request/UserRegistrationRequest.java new file mode 100644 index 0000000..cf025b5 --- /dev/null +++ b/src/main/java/com/vodacom/falcon/model/request/UserRegistrationRequest.java @@ -0,0 +1,4 @@ +package com.vodacom.falcon.model.request; + +public record UserRegistrationRequest(String username, String password) { +} diff --git a/src/main/java/com/vodacom/falcon/model/response/ExchangeRateResponse.java b/src/main/java/com/vodacom/falcon/model/response/ExchangeRateResponse.java index 8453ccb..561d7e9 100644 --- a/src/main/java/com/vodacom/falcon/model/response/ExchangeRateResponse.java +++ b/src/main/java/com/vodacom/falcon/model/response/ExchangeRateResponse.java @@ -2,5 +2,5 @@ import java.util.Map; -public record ExchangeRateResponse(String base, Map rates) { +public record ExchangeRateResponse(String date, String base, Map rates) { } diff --git a/src/main/java/com/vodacom/falcon/model/response/InsightResponse.java b/src/main/java/com/vodacom/falcon/model/response/InsightResponse.java index 95b4aef..2255dd8 100644 --- a/src/main/java/com/vodacom/falcon/model/response/InsightResponse.java +++ b/src/main/java/com/vodacom/falcon/model/response/InsightResponse.java @@ -12,6 +12,7 @@ @AllArgsConstructor @Builder public class InsightResponse { + private MetadataResponse metadata; private EconomyInsightResponse economyInsight; private ExchangeRateResponse exchangeRate; private WeatherForecastResponse weatherForecast; diff --git a/src/main/java/com/vodacom/falcon/model/response/MetadataResponse.java b/src/main/java/com/vodacom/falcon/model/response/MetadataResponse.java new file mode 100644 index 0000000..350ad9f --- /dev/null +++ b/src/main/java/com/vodacom/falcon/model/response/MetadataResponse.java @@ -0,0 +1,17 @@ +package com.vodacom.falcon.model.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Setter +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MetadataResponse { + private boolean isAuthenticatedUser; + private String message; +} diff --git a/src/main/java/com/vodacom/falcon/model/response/openweathermap/OpenDailyWhetherResponse.java b/src/main/java/com/vodacom/falcon/model/response/openweathermap/OpenDailyWhetherResponse.java index 07f2b88..ffa31e6 100644 --- a/src/main/java/com/vodacom/falcon/model/response/openweathermap/OpenDailyWhetherResponse.java +++ b/src/main/java/com/vodacom/falcon/model/response/openweathermap/OpenDailyWhetherResponse.java @@ -1,5 +1,8 @@ package com.vodacom.falcon.model.response.openweathermap; +import java.util.List; + public record OpenDailyWhetherResponse(String dt, String sunrise, String sunset, - String summary, OpenDailyTemperatureWeatherResponse temp) { + String summary, List weather, + OpenDailyTemperatureWeatherResponse temp) { } diff --git a/src/main/java/com/vodacom/falcon/repository/UserRepository.java b/src/main/java/com/vodacom/falcon/repository/UserRepository.java new file mode 100644 index 0000000..2707f82 --- /dev/null +++ b/src/main/java/com/vodacom/falcon/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.vodacom.falcon.repository; + + +import com.vodacom.falcon.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface UserRepository extends JpaRepository { + User findByUsername(String username); +} diff --git a/src/main/java/com/vodacom/falcon/service/AuthService.java b/src/main/java/com/vodacom/falcon/service/AuthService.java new file mode 100644 index 0000000..c0faca7 --- /dev/null +++ b/src/main/java/com/vodacom/falcon/service/AuthService.java @@ -0,0 +1,34 @@ +package com.vodacom.falcon.service; + + +import com.vodacom.falcon.config.security.PasswordEncoderProvider; +import com.vodacom.falcon.model.User; +import com.vodacom.falcon.model.request.UserRegistrationRequest; +import com.vodacom.falcon.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthService { + private final PasswordEncoderProvider encoderProvider; + private final UserRepository userRepository; + + public void createUser(UserRegistrationRequest auth) { + User existingUser = userRepository.findByUsername(auth.username()); + if (existingUser != null) { + throw new Error("User already exists! Please login"); + } + + User user = User.builder() + .username(auth.username()) + .password(encoderProvider.passwordEncoder().encode(auth.password())) + .createdAt(LocalDateTime.now()) + .build(); + userRepository.save(user); + } +} diff --git a/src/main/java/com/vodacom/falcon/service/ExchangeRateService.java b/src/main/java/com/vodacom/falcon/service/ExchangeRateService.java index e55fc6c..7559dbc 100644 --- a/src/main/java/com/vodacom/falcon/service/ExchangeRateService.java +++ b/src/main/java/com/vodacom/falcon/service/ExchangeRateService.java @@ -32,9 +32,9 @@ public ExchangeRateResponse getExchangeRates(String countryCode) { ExchangeRateResponse ratesFromMainSource = buildExchangeRates(mainExchangeRateUrl); - if (ratesFromMainSource != null) { - return ratesFromMainSource; - } +// if (ratesFromMainSource != null) { +// return ratesFromMainSource; +// } return buildExchangeRates(optionalExchangeRateUrl); } diff --git a/src/main/java/com/vodacom/falcon/service/InsightService.java b/src/main/java/com/vodacom/falcon/service/InsightService.java index 7641636..bea7766 100644 --- a/src/main/java/com/vodacom/falcon/service/InsightService.java +++ b/src/main/java/com/vodacom/falcon/service/InsightService.java @@ -3,9 +3,14 @@ import com.vodacom.falcon.model.response.EconomyInsightResponse; import com.vodacom.falcon.model.response.ExchangeRateResponse; import com.vodacom.falcon.model.response.InsightResponse; +import com.vodacom.falcon.model.response.MetadataResponse; import com.vodacom.falcon.model.response.WeatherForecastResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import java.net.URLEncoder; @@ -29,13 +34,30 @@ public InsightResponse getInsight(String city) { WeatherForecastResponse weatherForecast = weatherForecastService.getWeatherForecast(encodedCity); - String countryCode = weatherForecast.getForecast().getLocation().getCountryCode(); + String countryCode = weatherForecast + .getForecast() + .getLocation() + .getCountryCode(); + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + MetadataResponse metadata = new MetadataResponse(); + metadata.setAuthenticatedUser(true); + metadata.setMessage("Enjoy your destination %s! "); - EconomyInsightResponse economyInsight = economyInsightService.getEconomyInsight(countryCode, WB_FILTER_DATE); ExchangeRateResponse exchangeRateResponse = exchangeRateService.getExchangeRates(countryCode); + if (authentication instanceof AnonymousAuthenticationToken) { + metadata.setAuthenticatedUser(false); + metadata.setMessage("Please login, to see Weather Forecast and Exchange Rates"); + weatherForecast = null; + exchangeRateResponse = null; + } + + EconomyInsightResponse economyInsight = economyInsightService.getEconomyInsight(countryCode, WB_FILTER_DATE); + return InsightResponse .builder() + .metadata(metadata) .economyInsight(economyInsight) .weatherForecast(weatherForecast) .exchangeRate(exchangeRateResponse) diff --git a/src/main/java/com/vodacom/falcon/service/WeatherForecastService.java b/src/main/java/com/vodacom/falcon/service/WeatherForecastService.java index 6e2ea2b..62e822f 100644 --- a/src/main/java/com/vodacom/falcon/service/WeatherForecastService.java +++ b/src/main/java/com/vodacom/falcon/service/WeatherForecastService.java @@ -61,13 +61,11 @@ private OpenLocationResponse getLocation(String city) { HttpResponse response = APICaller.getData(url); if (response != null) { Object[] object = deserialize(response.body(), Object[].class); - if (object != null && object[0] != null) { - return deserializeByTypeReference(serialize(object[0]), new TypeReference<>() { - }); + if (object != null) { + return object[0] != null ? deserializeByTypeReference(serialize(object[0]), new TypeReference<>() { + }) : null; } } return null; } - - } diff --git a/src/main/java/com/vodacom/falcon/util/FalconDefaults.java b/src/main/java/com/vodacom/falcon/util/FalconDefaults.java index 9a6c721..28809a4 100644 --- a/src/main/java/com/vodacom/falcon/util/FalconDefaults.java +++ b/src/main/java/com/vodacom/falcon/util/FalconDefaults.java @@ -9,4 +9,6 @@ public class FalconDefaults { public static final String MAIN_EXCHANGE_RATE_API_BASE_URL = "http://api.exchangeratesapi.io"; public static final String OPTIONAL_EXCHANGE_RATE_API_BASE_URL = "https://api.currencyfreaks.com"; public static final String COUNTRY_API_BASE_URL = "https://countriesnow.space"; + public static final String AUTHORIZATION = "Authorization"; + public static final String BASIC = "Basic "; } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ff07af1..67d251f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1 @@ -spring.application.name=voda-travel +pring.application.name=voda-travel diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f63cad9..e448fb8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,12 +1,26 @@ server: port: 8083 -# FIXME: Temporally available, for testing propose. SHOULD REMOVE them. +spring: + datasource: + url: jdbc:postgresql://localhost:5432/falcon # ${db} + username: admin # ${user} + password: 123 + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + +# FIXME: SECURITY CONCERN !!! +# THE FOLLOWING KEYS WERE NOT SUPPOSED TO BE CLEAR, BUT LEAVING AS THEY ARE FOR YOUR TESTING. + open-weather-map: - apiKeyV2: bd5e378503939ddaee76f12ad7a97608 # random from https://gist.github.com/lalithabacies/c8f973dc6754384d6cade282b64a8cb1 - apiKeyV3: 87ec2b8f0b40a00a3bdaf5f31953a186 + apiKeyV2: bd5e378503939ddaee76f12ad7a97608 # ${openWeatherApiKeyV2} random from https://gist.github.com/lalithabacies/c8f973dc6754384d6cade282b64a8cb1 + apiKeyV3: 87ec2b8f0b40a00a3bdaf5f31953a186 # ${openWeatherApiKeyV3} main-exchange-rates-api: - apiKey: c771abe4cb5cfe85674fae5142290f1a + apiKey: c771abe4cb5cfe85674fae5142290f1a # ${exchangeRatesApiKey} optional-exchange-rates-api: - apiKey: dc7d9a4fe0e74557b0b83a1fcf66dd2a \ No newline at end of file + apiKey: dc7d9a4fe0e74557b0b83a1fcf66dd2a # ${exchangeRatesApiKey} \ No newline at end of file