Microservices: Set Up a Gateway with UI (Thymeleaf)
Durian Sosa
Posted on July 1, 2024
Hello there, this is the structure of my microservices project with Spring;
*1 Gateway (this is what I will cover in this chapter)
*
1 Authorization Server (OAuth2)
3 REST APIs (Account Service, Statistics Service, Notification Service)
1 Service Registry
1 Config Server
This is how it looks like
The Gateway has a UI made with Thymeleaf, and the corresponding CSS and JavaScript js code only has jQuery as external library. The purpose of jQuery is to make using JavaScript on your website much easier.
What our Gateway needs:
- The main role of a Gateway: Redirect requests to the correct Service, this is usually done with Spring Gateway but since we need to make an Authentication with the Authentication Server, this will be done with a custom WebClient instead (Please suggest any other solutions you have)
Take into account that it is the USER who needs to authenticate with the AUTHORIZATION SERVER.
Dince Implicit Grant Flow is deprecated, I could not find a way for the Gateway to authenticate on the user's behalf, also I did not insist on it because it would go against the whole idea of the OAuth2 Code-Grant Flow (where it is always the Resource Owner a.k.a. the 'user' who authenticates and grant access to a third-party service, app or whatever it is). Please tell me if I'm wrong here.
Redirect the user to the Authentication Server, so the user grants access to the Gateway to his data in other services.
Send requests with the AccessToken to all the services (This is where WebClient comes in).
Good templates to show the HTML to the user
Are you ready? Okay, let's go!
Now the HTML page, the index.html shows how the final result should be when the data is available from the backend. For the actual code that contains Thymeleaf instructions, there are the fragments:
However, it may be different for your project, and I would like to focus on the general aspects of microservices:
- The app must redirect the user to the AuthServer, we do this with the "ExceptionHandler" of our SecurityConfig, thanks to this every unauthenticated request is redirected to the Authorization Server via the authorization endpoint of the oauth2 flow
SecurityConfig:
@Bean
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity serverHttpSecurity) throws Exception {
serverHttpSecurity
.csrf(csrf -> csrf.disable())
.cors(corsSpec -> corsSpec.configurationSource(corsConfigurationSource()))
.oauth2Login(spec -> spec
.authenticationSuccessHandler(redirectSuccessHandler())
.authenticationFailureHandler(failureHandler())
.authorizationRequestResolver(customAuthorizationRequestResolver()))
//redirect to Oauth2Server by default, oauth2server is in charge of login in and registering new users
.exceptionHandling(exceptionHandlingSpec -> exceptionHandlingSpec.authenticationEntryPoint(new RedirectServerAuthenticationEntryPoint("/oauth2/authorization/gatewayClient")))
.authorizeExchange( exchanges ->
exchanges
.pathMatchers("/css/**").permitAll()
.pathMatchers("/images/**").permitAll()
.pathMatchers("/js/**").permitAll()
.anyExchange().authenticated()
)
.logout(logoutSpec -> logoutSpec
.logoutUrl("/logout")
.logoutSuccessHandler(oidcLogoutSuccessHandler()))
.oauth2Client(Customizer.withDefaults())
.oauth2ResourceServer(rs -> rs.jwt(jwt -> jwtConfigCustomizer()));
return serverHttpSecurity.build();
}
Step 2 and 3. Redirect the user's requests to the corresponding service.
Supposing the user already granted access to the gateway after the Grant-Code Flow from OAuth2, we also need to attach the AccessToken as #a Bearer Token in the header of each request.
#It is Load Balanced with Spring Discovery Client so to
#find any service we can use the 'application name' in the
#URI.
@Configuration
public class WebClientConfig {
public WebClientConfig() {
}
@Bean
@LoadBalanced
public WebClient.Builder webClientBuilder(ReactiveClientRegistrationRepository repository, ReactiveOAuth2AuthorizedClientService service) {
AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager manager = new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(repository, service);
ServerOAuth2AuthorizedClientExchangeFilterFunction oauthFilterFunction = new ServerOAuth2AuthorizedClientExchangeFilterFunction(manager);
String clientId = "gatewayClient";
oauthFilterFunction.setDefaultClientRegistrationId(clientId);
return WebClient.builder().baseUrl("http://gateway:8061").filter(oauthFilterFunction);
}
}
Step 4 and 5. Provide the Templates and Static Files
Next comes the web configuration. Its purposes are:
To provide everything Thymeleaf needs to work (TemplateResolver, TemplateEngine, ViewResolver)
Indicate where MessageSources are found (this makes possible to serve the texts of our page in different languages)
Configure the endpoints that should return static resources (as CSS, JS and Images)
here you go:
@Configuration
public class WebGlobalConfiguration implements ApplicationContextAware, WebFluxConfigurer {
private ApplicationContext applicationContext;
public WebGlobalConfiguration() {
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
//Thymeleaf Config
@Bean
public SpringResourceTemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setApplicationContext(applicationContext);
templateResolver.setPrefix("classpath:/templates/");
templateResolver.setSuffix(".html");
return templateResolver;
} @Bean
public ResourceBundleMessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
return messageSource;
}
@Bean
public SpringWebFluxTemplateEngine templateEngine() {
SpringWebFluxTemplateEngine templateEngine = new SpringWebFluxTemplateEngine();
templateEngine.setTemplateResolver(templateResolver());
templateEngine.setMessageSource(messageSource());
return templateEngine;
}
@Bean
public ThymeleafReactiveViewResolver viewResolver() {
ThymeleafReactiveViewResolver viewResolver = new ThymeleafReactiveViewResolver();
viewResolver.setTemplateEngine(templateEngine());
return viewResolver;
}
//Add Resourcer Handlers & View Resolvers
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/images/**").addResourceLocations("classpath:/static/images/");
registry.addResourceHandler("/css/**").addResourceLocations("classpath:/static/css/");
registry.addResourceHandler("/js/**").addResourceLocations("classpath:/static/js/");
}
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.viewResolver(viewResolver());
}
}
The WebController to process requests and provide the appropriate template as response :
You will see some ENUM, this only applies to the Business Logic of my project, you can overlook them
@Controller
public class WebController {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private AccountService accountService;
private StatsService statsService;
private ReactiveOAuth2AuthorizedClientService authorizedClientService;
public WebController(AccountService accountService, StatsService statsService, ReactiveOAuth2AuthorizedClientService authorizedClientService) {
this.accountService = accountService;
this.statsService = statsService;
this.authorizedClientService = authorizedClientService;
}
@ModelAttribute(name = "allAccountAvatar")
public List<AccountAvatar> allAccountIcons() {
return Arrays.asList(AccountAvatar.ALL);
}
@ModelAttribute(name = "allItemIcons")
public List<ItemIcon> allItemIcons() {
return Arrays.asList(ItemIcon.ALL);
}
@ModelAttribute(name = "allCategories")
public List<Category> loadCategories() {
return Arrays.asList(Category.ALL);
}
@ModelAttribute(name = "allCurrencies")
public List<Currency> loadCurrencies() {
return Arrays.asList(Currency.ALL);
}
@ModelAttribute(name = "allFrequencies")
public List<Frequency> loadFrequencies() {
return Arrays.asList(Frequency.ALL);
}
@RequestMapping(method = {RequestMethod.GET}, value = "/demo")
public String showDemoPage(
Model model
) {
Mono<AccountDTO> accountDTO = accountService.getAccount("demo");
model.addAttribute("account", accountDTO);
model.addAttribute("logged", false);
return "index";
}
@RequestMapping(method = {RequestMethod.GET}, value = "/index")
public String showIndexPage(
@RegisteredOAuth2AuthorizedClient(registrationId = "gatewayClient")
OAuth2AuthorizedClient gatewayClient,
Authentication authentication,
Model model) {
//Initializing account Mono, we define it later.
// Because it is necessary to check if the account already exists
//Getting username from the resource owner's authentication
Mono<AccountDTO> accountMono;
DefaultOidcUser user = (DefaultOidcUser) authentication.getPrincipal();
String username = user.getName();
//If account is null, then the gateway gets it or creates a new account
if (model.getAttribute("account") == null) {
accountMono = accountService.getAccount(username);
model.addAttribute("account", accountMono);
//Get stats of account or null if empty
Flux<StatsDTO> statsDTOFlux = statsService.getStatsOfAccount(username);
IReactiveDataDriverContextVariable statsVariable = new ReactiveDataDriverContextVariable(statsDTOFlux);
model.addAttribute("stats", statsVariable);
model.addAttribute("logged", false);
}
return "index";
}
//This is a form to edit the account details such as accountName, Notes and Currency
@RequestMapping(method = {RequestMethod.POST}, value = "/edit/{accountName}")
public String editAccount(@PathVariable String accountName, @ModelAttribute(name = "account") AccountDTO account, BindingResult bindingResult, Model model) {
Mono<AccountDTO> updatedAccount = accountService.editAccount(accountName, account);
model.addAttribute("account", updatedAccount);
model.addAttribute("logged", true);
return "index";
}
@RequestMapping(method = {RequestMethod.POST}, value = "/save/{accountName}")
public String saveAccountChanges(@PathVariable String accountName,
@ModelAttribute(name = "account") AccountDTO account,
BindingResult bindingResult,
Model model) {
//This updates account's items and expenses
Mono<AccountDTO> updatedAccount = accountService.editAccountItems(accountName, account).flatMap(
accountDTO -> {
statsService.saveStatsOfAccount(accountDTO);
return Mono.just(accountDTO);
}
);
Flux<StatsDTO> statsDTOFlux = statsService.getStatsOfAccount(accountName).delayElements(Duration.ofMillis(5000));
IReactiveDataDriverContextVariable statsVariable = new ReactiveDataDriverContextVariable(statsDTOFlux);
model.addAttribute("account", updatedAccount);
model.addAttribute("stats", statsVariable);
model.addAttribute("logged", true);
return "index";
}
}
The HTML, CSS and JavaScript is very personal so it may vary depending on your project, also I don't want to make the post extremely large, but if you want to take a look at them I can leave them in the comments.
The final result:
Posted on July 1, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.