Microservices: Set Up a Gateway with UI (Thymeleaf)

dmsosa

Durian Sosa

Posted on July 1, 2024

Microservices: Set Up a Gateway with UI (Thymeleaf)

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.

Image description

What our Gateway needs:

  1. 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.

  1. Redirect the user to the Authentication Server, so the user grants access to the Gateway to his data in other services.

  2. Send requests with the AccessToken to all the services (This is where WebClient comes in).

  3. Good templates to show the HTML to the user

  4. Static files, such as CSS, JavaScript and images
    These are stored in the Resource folder of the app

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:

Image description

However, it may be different for your project, and I would like to focus on the general aspects of microservices:

  1. 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();
    }

Enter fullscreen mode Exit fullscreen mode

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);
    }
}

Enter fullscreen mode Exit fullscreen mode

Step 4 and 5. Provide the Templates and Static Files

Next comes the web configuration. Its purposes are:

  1. To provide everything Thymeleaf needs to work (TemplateResolver, TemplateEngine, ViewResolver)

  2. Indicate where MessageSources are found (this makes possible to serve the texts of our page in different languages)

  3. 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());
    }


}

Enter fullscreen mode Exit fullscreen mode

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";
    }
}

Enter fullscreen mode Exit fullscreen mode

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:

Image description

Image description

Image description

Image description

💖 💪 🙅 🚩
dmsosa
Durian Sosa

Posted on July 1, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related