Search code examples
spring-bootspring-securityazure-active-directoryvaadinvaadin-flow

Vaadin User login plus Azure AD login not working


I've building a web application using java 17, vaadin 23.3.5 and spring boot 2.7.5. I have already successfully integrated a custom user management, customized from the default vaadin user management. The new requirement from the stakeholder is that they want to be able to login via Azure AD since they manage all their company users from there. But they still want to have the ability to login a user via the default login.

Since I'm using Maven 3, I declared all necessary dependencies:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.vaadin</groupId>
            <artifactId>vaadin-bom</artifactId>
            <version>${vaadin.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>com.azure.spring</groupId>
            <artifactId>spring-cloud-azure-dependencies</artifactId>
            <version>4.11.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</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-oauth2-client</artifactId>
    </dependency>
    <dependency>
        <groupId>com.azure.spring</groupId>
        <artifactId>spring-cloud-azure-starter-active-directory</artifactId>
    </dependency>
    <dependency>
        <groupId>com.microsoft.azure</groupId>
        <artifactId>msal4j</artifactId>
        <version>1.13.5</version>
    </dependency>
</dependencies>

In the application.properties, I configured the connection params:

    spring.cloud.azure.active-directory.enabled=true
    spring.cloud.azure.active-directory.profile.tenant-id=xxx
    spring.cloud.azure.active-directory.credential.client-id=yyy
    spring.cloud.azure.active-directory.credential.client-secret=zzz
    spring.cloud.azure.active-directory.redirect-uri-template=http://localhost:8080/login/oauth2/code/azure
    spring.security.oauth2.client.access-token-uri=https://login.microsoftonline.com/common/oauth2/v2.0/token
    spring.security.oauth2.client.user-authorization-   uri=https://login.microsoftonline.com/common/oauth2/v2.0/authorize
    logging.level.com.azure.spring.cloud=trace

For checking if a user is authenticated, I customized the AuthenticatedUser class from vaadin, so that it should handle DefaultOidcUser objects coming from Azure AD:

@Component
public class AuthenticatedUser {

    @Autowired
    private HttpSession session;
    @Autowired
    protected OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService;

    private final UserRepository userRepository;
    private final AuthenticationContext authenticationContext;

    public AuthenticatedUser(AuthenticationContext authenticationContext, UserRepository userRepository) {
        this.userRepository = userRepository;
        this.authenticationContext = authenticationContext;
    }

    private Set<Role> mapRoles(List<String> aadRoles) {
        Map<String, Role> dict = new HashMap<>();
        dict.put("app.admin", Role.ADMIN);
        dict.put("app.user", Role.USER);

        Set<Role> roles = new HashSet<>();
        for (String aadRole : aadRoles) {
            if (dict.containsKey(aadRole)) {
                roles.add(dict.get(aadRole));
            }
        }
        return roles;
    }

    @Transactional
    public Optional<User> get() {
        try {
            Optional<DefaultOidcUser> aadUser = authenticationContext.getAuthenticatedUser(DefaultOidcUser.class);

            if (aadUser.isPresent()) {
                // if a context is found from azure ad, create a dummy user to work with
                User user = new User();
                user.setUsername(aadUser.get().getPreferredUsername());
                user.setName(aadUser.get().getName());
                user.setId((long)((String) aadUser.get().getAttribute("oid")).hashCode());

                List<String> aadRoles = aadUser.get().getAttribute("roles");
                user.setRoles(mapRoles(aadRoles));

                return Optional.of(user);
            }

        } catch (ClassCastException e) {
            return authenticationContext.getAuthenticatedUser(UserDetails.class)
                    .map(userDetails -> userRepository.findByUsername(userDetails.getUsername()));
        }
        return Optional.empty();
    }

    public void logout() {
        Optional<DefaultOidcUser> aadUser = authenticationContext.getAuthenticatedUser(DefaultOidcUser.class);
        if (aadUser.isPresent()) {
            UI.getCurrent().getPage().setLocation("/logout");
        } else {
            authenticationContext.logout();
        }
    }

}

I then decorated the default LoginView to have an additional button to allow login via Azrue AD, rerouting to "/oauth2/authorization/azure": Login View

Now the part where I am absolutely unsure if what I did does have any purpose, is the configuration part:

@EnableWebSecurity
@Configuration
public class SecurityConfiguration extends VaadinWebSecurity {

    /**
     * A repository for OAuth 2.0 / OpenID Connect 1.0 ClientRegistration(s).
     */
    @Autowired
    protected ClientRegistrationRepository repo;


    /**
     * restTemplateBuilder bean used to create RestTemplate for Azure AD related http request.
     */
    @Autowired
    protected RestTemplateBuilder restTemplateBuilder;

    /**
     * OIDC user service.
     */
    @Autowired
    protected OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService;

    /**
     * AAD authentication properties
     */
    @Autowired
    protected AadAuthenticationProperties properties;

    /**
     * JWK resolver implementation for client authentication.
     */
    @Autowired
    protected ObjectProvider<OAuth2ClientAuthenticationJwkResolver> jwkResolvers;


    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.csrf().disable();
        http.cors().disable();



        Filter conditionalAccessFilter = conditionalAccessFilter();
        if (conditionalAccessFilter != null) {
            http.addFilterAfter(conditionalAccessFilter, OAuth2AuthorizationRequestRedirectFilter.class);
        }


        http
                .oauth2Login(oauth2Login ->
                        oauth2Login
                                .authorizationEndpoint()
                                .authorizationRequestResolver(requestResolver())
                                .and()
                                .tokenEndpoint()
                                .accessTokenResponseClient(accessTokenResponseClient())
                                .and()
                                .userInfoEndpoint()
                                .oidcUserService(oidcUserService)
                )
                .logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessHandler(oidcLogoutSuccessHandler());

        http.authorizeRequests()
                .requestMatchers(new AntPathRequestMatcher("/api/**"))
                .permitAll();

        http.authorizeRequests(authorizeRequests ->
                authorizeRequests
                        .requestMatchers(new AntPathRequestMatcher("/images/*.png")).permitAll()
                        .requestMatchers(new AntPathRequestMatcher("/line-awesome/**/*.svg")).permitAll()
                        //.antMatchers("/login").anonymous() // Allow /login for Vaadin login
                        .antMatchers("/oauth2/authorization/azure").authenticated()// Require authentication for Azure
                        //.anyRequest().authenticated() // All other requests require authentication
        );
        http.formLogin() // Use form-based login for /login
                .loginPage("/login") // Specify the login page URL
                .permitAll();
        http.exceptionHandling()
                .accessDeniedHandler(accessDeniedHandler());

        super.configure(http);

        setLoginView(http, LoginView.class);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
        web.ignoring().antMatchers("/images/*.png");
    }

    @Bean
    public AccessDeniedHandler accessDeniedHandler() {
        return new VaadinAccessDeniedHandler();
    }

    protected OAuth2AuthorizationRequestResolver requestResolver() {
        return new AadOAuth2AuthorizationRequestResolver(this.repo, properties);
    }

    protected OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {
        DefaultAuthorizationCodeTokenResponseClient result = new DefaultAuthorizationCodeTokenResponseClient();
        result.setRestOperations(createOAuth2AccessTokenResponseClientRestTemplate(restTemplateBuilder));
        if (repo instanceof AadClientRegistrationRepository) {
            AadOAuth2AuthorizationCodeGrantRequestEntityConverter converter =
                    new AadOAuth2AuthorizationCodeGrantRequestEntityConverter(
                            ((AadClientRegistrationRepository) repo).getAzureClientAccessTokenScopes());
            OAuth2ClientAuthenticationJwkResolver jwkResolver = jwkResolvers.getIfUnique();
            if (jwkResolver != null) {
                converter.addParametersConverter(new AadJwtClientAuthenticationParametersConverter<>(jwkResolver::resolve));
            }
            result.setRequestEntityConverter(converter);
        }
        return result;
    }

    protected LogoutSuccessHandler oidcLogoutSuccessHandler() {
        OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
                new OidcClientInitiatedLogoutSuccessHandler(this.repo);
        String uri = this.properties.getPostLogoutRedirectUri();
        if (StringUtils.hasText(uri)) {
            oidcLogoutSuccessHandler.setPostLogoutRedirectUri(uri);
        }
        return oidcLogoutSuccessHandler;
    }

    protected Filter conditionalAccessFilter() {
        return null;
    }
}

I have two views (HelloWorld and About). HelloWorldView has these annotations:

@PageTitle("Hello World")
@Route(value = "hello", layout = MainLayout.class)
@RouteAlias(value = "", layout = MainLayout.class)
@AnonymousAllowed

while AboutView only uses

@PageTitle("About")
@Route(value = "about", layout = MainLayout.class)
@RolesAllowed({"APPROLE_app.user", "ROLE_USER"})

So HelloWorldView should be accessible without authentication. But I got two main problems:

  1. When I use a simple application, fresh from start.vaadin.com, I get something that works at least (even if not beautifully). But since I want to use server.servlet.context-path and vaadin.urlMapping=/ui/*, as soon as I add those two properties to the application.properties, nothing works anymore. Not the default login with admin/admin or user/user, nor the azure ad login.
  2. When I apply my working setting from bullet point 1 to the complex business application I built so far and remove server.servlet.context-path and vaadin.urlMapping=/ui/*, I can't even access the oauth2 login page from Azure AD.

It would be great if someone with experience in Azure AD + Vaadin or Spring Boot could guide me or at least point out some missing points or errors.


Solution

  • I resolved my issue by simply using this configuration:

    http.authorizeRequests()
            .requestMatchers(new AntPathRequestMatcher("/oauth2/authorization/azure"))
            .permitAll();
    http.oauth2Login(oauthLogin ->
                oauthLogin.userInfoEndpoint(uie -> uie.userService(oidcUserService()))
                        .tokenEndpoint()
                        .accessTokenResponseClient(accessTokenResponseClient())
    );
    

    and configuring the following beans:

    @Bean
    public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {
        return new DefaultAuthorizationCodeTokenResponseClient();
    }
    
    @Bean
    public DefaultOAuth2UserService oidcUserService() {
        return new DefaultOAuth2UserService() {
            @Override
            public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
                OAuth2User user = super.loadUser(userRequest);
                return user;
            }
        };
    }
    

    By using the AuthenticatedUser class mentioned above, I can simply use the default Vaadin login using the spring security and use oauth2 to login with azure ad.