简体   繁体   中英

Spring Boot 2.3 and Spring Security 5 - Support UserDetailsService and OAuth2 in one schema

I'm building a Java webapp using Spring Boot 2.3.x, Spring Security 5 and Thymeleaf, running on Java 11.

The app needs to support user accounts of some kind. As a starting point, I followed the approach used by John Thompson (aka Spring Framework Guru) in his " Spring Security Core: Beginner to Guru " course. John's approach uses Spring Data JPA and HTTP Basic authentication, where I implement the Spring interface UserDetailsService and allow the app to load user credentials (username, password, roles, authorities) from the database on demand during HTTP Basic authentication. This all works well. Because I store each user's roles/authorities in my database, I have full control and can use them with Spring Security method-level annotations like this: @PreAuthorize("hasAuthority('user.details.read')") . Again, this all works great.

The problem with John's approach from the course material is that I'm limited to HTTP Basic and storing/managing all user passwords.

Yesterday I experimented with the OAuth 2.0 features of Spring Security 5 to 'login with Facebook'. I used some of the code from this tutorial page to get started. In isolation, this works well for my application to authenticate a user as a Facebook member. Unfortunately, this supplies a different kind of @AuthenticationPrincipal object that contains roles and authorities that are only relevant to Facebook.

THE PROBLEM

I now have two disconnected types of users:

  1. HTTP Basic authenticated users, whose credentials, roles and authorities I manage. Their roles/authorites are appropriate for my application.
  2. OAuth2 authenticated users, whose credentials, roles and authorities are out of my control. Their roles/authorities are unrelated (and irrelevant) for my application.

The end-state I want is:

  • My application database stores the roles/authorities assigned to each user
  • The application will support authentication via either HTTP Basic OR OAuth2 (to Facebook initially), but my application database will supply the roles/authorities
  • The 'unique identifier' for each user will be their email address (Facebook OAuth2 supplies this as an attribute), so I hope this can be used to associate the HTTP Basic and OAuth2 authentication objects
  • A user could setup both HTTP Basic and OAuth2 for their account, and if so, they can login with either method. Either way, they will still have the same role/authorities in my app.

To summarize: I just want Facebook OAuth2 to confirm 'this is an active Facebook user whose email address is xyz@example.com' and then link their Facebook account with a user account in my application.

WHAT NEXT?

Spring does a great job of providing 'reasonable defaults' for the components of each of the separate use-cases (HTTP Basic versus OAuth2). I suspect that I'll need to override and/or disable some of these component behaviors to get what I'm looking for. I just don't know where to start.

CODE SO FAR

I've provided some code samples below of the pieces I have working. As I mentioned above, the parts related to UserDetailsService and the JPA entities that feed that service already work well. My question is essentially 'How do I incorporate OAuth2 into what already works?'.

My WebSecurityConfigurerAdapter implementation class

@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Configuration
public class SpringSecurityConfiguration extends WebSecurityConfigurerAdapter {

    private final UserDetailsService userDetailsService;
    private final PersistentTokenRepository persistentTokenRepository;


    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        // @formatter:off
        httpSecurity
                .authorizeRequests(authorize -> {
                    authorize
                        // The following paths do not require the user to be authenticated by Spring Security
                        .antMatchers("/", "/favicon.ico", "/login", "/login-form", "/vendor/**", "/images/**").permitAll()
                        // Allow anonymous access to webjars
                        .antMatchers("/webjars/**").permitAll()
                        // Allow anonymous access to all enabled actuators
                        .antMatchers("/actuator/**").permitAll()
                        // This should only be relevant in a non-production environment
                        .antMatchers("/h2-console/**").permitAll();
                })
                // All other request paths not covered by the list above can only be viewed by an authenticated user
                .authorizeRequests().anyRequest().authenticated()
            .and()
                // Explicitly defining a login and logout configurer will implicitly disable
                // the built-in login/logout forms provided by Spring
                .formLogin(loginConfigurer -> {
                    // If the user enters the path "/login", then display the main page (at "/").
                    // The main page contains a login form.
                    loginConfigurer
                            .loginProcessingUrl("/login")
                            //.loginPage("/").permitAll()
                            .loginPage("/login-form").permitAll()
                            //.successForwardUrl("/")
                            //.defaultSuccessUrl("/")
                            .defaultSuccessUrl("/formLoginSuccess")
                            // Add an 'error' parameter to the success URL so a Thymeleaf template
                            // could conditionally display something if a login failure occurs
                            .failureUrl("/?error");
                })
                .logout(logoutConfigurer -> {
                    // If the user enters the path "/logout", then log them out and then navigate to the main page
                    logoutConfigurer
                            .logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))
                            // Add a 'logout' parameter to the success URL so the Thymeleaf template
                            // can conditionally display a friendly message upon successful logout
                            .logoutSuccessUrl("/?logout")
                            .permitAll();
                })
                // Use HTTP Basic authentication
                .httpBasic()
            .and()
                .oauth2Login()
                    .loginPage("/login-form").permitAll()
                    .defaultSuccessUrl("/oauth2LoginSuccess", true)
            .and()
                .rememberMe()
                .tokenRepository(persistentTokenRepository)
                .userDetailsService(userDetailsService)
            .and()
                .csrf()
                    // CSRF will break the H2 console, so ignore it
                    .ignoringAntMatchers("/h2-console/**")
                    // The OAuth2 tutorial for Facebook says that CSRF will interfere with "/logout" via HTTP GET (I never confirmed that)
                    // REFERENCE: https://medium.com/@mail2rajeevshukla/spring-security-5-3-oauth2-integration-with-facebook-along-with-form-based-login-767e10b02dbc
                    .ignoringAntMatchers("/logout")
            .and()
                // Needed to allow the H2 console to function correctly
                .headers().frameOptions().sameOrigin();
        // @formatter:on
    }

}

Controller for my login form that supports either 'Login with Facebook' or HTTP Basic

@Slf4j
@RequiredArgsConstructor
@Controller
public class LoginFormController {

    @Autowired
    private final OAuth2AuthorizedClientService oauth2AuthorizedClientService;

    @RequestMapping("/login-form")
    public String getLoginForm() {
        return "login-form";
    }

    @RequestMapping("/oauth2LoginSuccess")
    public String getOauth2LoginInfo(
            Model model,
            @AuthenticationPrincipal OAuth2AuthenticationToken authenticationToken) {

        log.info("USER AUTHENTICATED WITH OAUTH2");

        // This will be something like 'facebook' or 'google' - describes the service that supplied the token
        log.info("auth token 'authorized client registration id': [{}]", authenticationToken.getAuthorizedClientRegistrationId());
        // A unique id for this user on the service that supplied the token (this is a long integer value on Facebook)
        log.info("auth token 'name': [{}]", authenticationToken.getName());

        if (!(authenticationToken.getPrincipal() instanceof OAuth2User)) {
            throw new IllegalStateException("Expected principal object to be of type '" + OAuth2User.class.getName() + "'");
        }
        final OAuth2User oauth2User = authenticationToken.getPrincipal();
        // 'oauth2User.getName()' returns the same long integer value on Facebook as the call to 'authenticationToken.getName()'
        log.info("oauth2User 'name': [{}]", oauth2User.getName());
        for (String key : oauth2User.getAttributes().keySet()) {
            // For Facebook OAuth2, the 'email' attribute is most important to me.
            // The 'name' attribute may also be useful. It contains a user-friendly name like 'Jim Tough'.
            log.info("oauth2User '{}' attribute value: [{}]", key, oauth2User.getAttributes().get(key));
        }

        OAuth2AuthorizedClient client =
                oauth2AuthorizedClientService.loadAuthorizedClient(
                        authenticationToken.getAuthorizedClientRegistrationId(),
                        authenticationToken.getName());
        log.info("Client token value: [{}]", client.getAccessToken().getTokenValue());

        model.addAttribute("authenticatedUsername", oauth2User.getAttribute("email"));
        model.addAttribute("authenticationType", "OAuth2");
        model.addAttribute("oauth2Provider", authenticationToken.getAuthorizedClientRegistrationId());

        return "login-form";
    }

    @RequestMapping("/formLoginSuccess")
    public String getFormLoginInfo(
            Model model,
            @AuthenticationPrincipal Authentication authentication) {

        log.info("USER AUTHENTICATED WITH HTTP BASIC");

        if (!(authentication.getPrincipal() instanceof UserDetails)) {
            throw new IllegalStateException("Expected principal object to be of type '" + UserDetails.class.getName() + "'");
        }
        // In form-based login flow you get UserDetails as principal
        final UserDetails userDetails = (UserDetails) authentication.getPrincipal();

        model.addAttribute("authenticatedUsername", userDetails.getUsername());
        model.addAttribute("authenticationType", "HttpBasic");
        model.addAttribute("oauth2Provider", null);

        return "login-form";
    }

}

My UserDetailsService implementation class (for HTTP Basic auth)

@Slf4j
@RequiredArgsConstructor
@Service
public class JPAUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Transactional
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.debug("Retrieving user details for [{}] from database", username);
        return userRepository.findByUsername(username).orElseThrow(() ->
                new UsernameNotFoundException("username [" + username + "] not found in database")
        );
    }

}

My UserRepository definition

public interface UserRepository extends JpaRepository<User, Integer> {

    Optional<User> findByUsername(String username);

}

My User entity

@Setter
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Entity
public class User implements UserDetails, CredentialsContainer {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Integer id;

    private String username;
    private String password;

    @Singular
    @ManyToMany(cascade = {CascadeType.MERGE}, fetch = FetchType.EAGER)
    @JoinTable(name = "user_role",
        joinColumns = {@JoinColumn(name = "USER_ID", referencedColumnName = "ID")},
        inverseJoinColumns = {@JoinColumn(name = "ROLE_ID", referencedColumnName = "ID")})
    private Set<Role> roles;

    @Transient
    public Set<GrantedAuthority> getAuthorities() {
        return this.roles.stream()
                .map(Role::getAuthorities)
                .flatMap(Set::stream)
                .map(authority -> {
                    return new SimpleGrantedAuthority(authority.getPermission());
                })
                .collect(Collectors.toSet());
    }

    @Override
    public boolean isAccountNonExpired() {
        return this.accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return this.accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return this.credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }

    @Builder.Default
    private Boolean accountNonExpired = true;

    @Builder.Default
    private Boolean accountNonLocked = true;

    @Builder.Default
    private Boolean credentialsNonExpired = true;

    @Builder.Default
    private Boolean enabled = true;

    @Override
    public void eraseCredentials() {
        this.password = null;
    }

    @CreationTimestamp
    @Column(updatable = false)
    private Timestamp createdDate;

    @UpdateTimestamp
    private Timestamp lastModifiedDate;

}

schema.sql - Creates a table used by the OAuth2 classes in Spring Security for persistent token storage

CREATE TABLE oauth2_authorized_client (
    client_registration_id varchar(100) NOT NULL,
    principal_name varchar(200) NOT NULL,
    access_token_type varchar(100) NOT NULL,
    access_token_value blob NOT NULL,
    access_token_issued_at timestamp NOT NULL,
    access_token_expires_at timestamp NOT NULL,
    access_token_scopes varchar(1000) DEFAULT NULL,
    refresh_token_value blob DEFAULT NULL,
    refresh_token_issued_at timestamp DEFAULT NULL,
    created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (client_registration_id, principal_name)
);

The log output from my LoginFormController looks like this when I login via Facebook OAuth2 using my own Facebook account:

[INFO ] LoginFormController - USER AUTHENTICATED WITH OAUTH2
[INFO ] LoginFormController - auth token 'authorized client registration id': [facebook]
[INFO ] LoginFormController - auth token 'name': [10139295061993788]
[INFO ] LoginFormController - oauth2User 'name': [10139295061993788]
[INFO ] LoginFormController - oauth2User 'id' attribute value: [10139295061993788]
[INFO ] LoginFormController - oauth2User 'name' attribute value: [Jim Tough]
[INFO ] LoginFormController - oauth2User 'email' attribute value: [jim@jimtough.com]

I also see this log output from another listener class of mine:

[INFO ] AuthenticationEventLogger - principal type: OAuth2LoginAuthenticationToken | authorities: [ROLE_USER, SCOPE_email, SCOPE_public_profile]

The authorities ROLE_USER, SCOPE_email, SCOPE_public_profile are meaningless in the context of my application.


I believe what you are looking for is the GrantedAuthoritiesMapper

You register a bean that will map your authorities to roles to be used.

@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .userAuthoritiesMapper(this.userAuthoritiesMapper())
                    ...
                )
            );
    }

    private GrantedAuthoritiesMapper userAuthoritiesMapper() {
        return (authorities) -> {
            Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

            authorities.forEach(authority -> {
                if (OidcUserAuthority.class.isInstance(authority)) {
                    OidcUserAuthority oidcUserAuthority = (OidcUserAuthority)authority;

                    OidcIdToken idToken = oidcUserAuthority.getIdToken();
                    OidcUserInfo userInfo = oidcUserAuthority.getUserInfo();

                    // Map the claims found in idToken and/or userInfo
                    // to one or more GrantedAuthority's and add it to mappedAuthorities

                } else if (OAuth2UserAuthority.class.isInstance(authority)) {
                    OAuth2UserAuthority oauth2UserAuthority = (OAuth2UserAuthority)authority;

                    Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();

                    // Map the attributes found in userAttributes
                    // to one or more GrantedAuthority's and add it to mappedAuthorities

                }
            });

            return mappedAuthorities;
        };
    }
}

It can also be mapped as a bean and automatically picked up by spring boot configuration.

@EnableWebSecurity
public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .oauth2Login(withDefaults());
    }

    @Bean
    public GrantedAuthoritiesMapper userAuthoritiesMapper() {
        ...
    }
}

You can read more about it here .

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM