【问题标题】:Spring Boot 2.3 and Spring Security 5 - Support UserDetailsService and OAuth2 in one schemaSpring Boot 2.3 和 Spring Security 5 - 在一个模式中支持 UserDetailsS​​ervice 和 OAuth2
【发布时间】:2021-04-09 16:36:37
【问题描述】:

我正在使用 Spring Boot 2.3.x、Spring Security 5 和 Thymeleaf 构建一个 Java webapp,在 Java 11 上运行。

应用需要支持某种类型的用户帐户。作为起点,我遵循了 John Thompson(又名 Spring Framework Guru)在他的“Spring Security Core: Beginner to Guru”课程中使用的方法。 John 的方法使用 Spring Data JPA 和 HTTP Basic 身份验证,其中我实现了 Spring 接口 UserDetailsService 并允许应用程序在 HTTP Basic 身份验证期间根据需要从数据库加载用户凭据(用户名、密码、角色、权限)。这一切都很好。因为我将每个用户的角色/权限存储在我的数据库中,所以我可以完全控制并且可以将它们与 Spring Security 方法级注释一起使用,如下所示:@PreAuthorize("hasAuthority('user.details.read')")。同样,这一切都很好。

课程材料中 John 的方法的问题在于我仅限于 HTTP Basic 和存储/管理所有用户密码。

昨天我尝试使用 Spring Security 5 的 OAuth 2.0 功能来“使用 Facebook 登录”。我使用了来自this tutorial page 的一些代码来开始。单独来看,这很适合我的应用程序将用户验证为 Facebook 成员。 不幸的是,这提供了一种不同类型的 @AuthenticationPrincipal 对象,其中包含仅与 Facebook 相关的角色和权限。

问题

我现在有两种断开连接的用户:

  1. HTTP Basic 身份验证用户,我管理其凭据、角色和权限。他们的角色/权限适合我的申请。
  2. 经过 OAuth2 身份验证的用户,其凭据、角色和权限不在我的控制范围内。他们的角色/权限与我的申请无关(也不相关)。

我想要的最终状态是:

  • 我的应用程序数据库存储分配给每个用户的角色/权限
  • 应用程序将支持通过 HTTP Basic 或 OAuth2 进行身份验证(最初对 Facebook),但我的应用程序数据库将提供角色/权限
  • 每个用户的“唯一标识符”将是他们的电子邮件地址(Facebook OAuth2 将其作为属性提供),因此我希望这可用于关联 HTTP Basic 和 OAuth2 身份验证对象
  • 用户可以为其帐户设置 HTTP Basic 和 OAuth2,如果是这样,他们可以使用任一方法登录。无论哪种方式,他们在我的应用中仍然具有相同的角色/权限。

总结一下:我只希望 Facebook OAuth2 确认“这是一个活跃的 Facebook 用户,其电子邮件地址为 xyz@example.com”,然后将他们的 Facebook 帐户与我的应用程序中的用户帐户相关联。

下一步是什么?

Spring 在为每个单独的用例(HTTP Basic 与 OAuth2)的组件提供“合理的默认值”方面做得很好。我怀疑我需要覆盖和/或禁用其中一些组件行为才能获得我正在寻找的东西。我只是不知道从哪里开始。

到目前为止的代码

我在下面提供了一些我正在工作的代码示例。正如我上面提到的,与UserDetailsService 相关的部分以及为该服务提供服务的 JPA 实体已经运行良好。我的问题本质上是“如何将 OAuth2 合并到已经工作的东西中?”。

我的WebSecurityConfigurerAdapter实现类

@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
    }

}

我的登录表单的控制器,支持“使用 Facebook 登录”或 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";
    }

}

我的UserDetailsService 实现类(用于 HTTP 基本身份验证)

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

}

我的UserRepository 定义

public interface UserRepository extends JpaRepository<User, Integer> {

    Optional<User> findByUsername(String username);

}

我的User 实体

@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 - 创建一个表,供 Spring Security 中的 OAuth2 类用于持久令牌存储​​

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

当我使用自己的 Facebook 帐户通过 Facebook OAuth2 登录时,LoginFormController 的日志输出如下所示:

[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]

我还看到了我的另一个侦听器类的日志输出:

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

当局ROLE_USER, SCOPE_email, SCOPE_public_profile 在我的申请中毫无意义。


【问题讨论】:

    标签: java spring-boot spring-mvc spring-security spring-security-oauth2


    【解决方案1】:

    我相信您正在寻找的是GrantedAuthoritiesMapper

    您注册了一个 bean,它将您的权限映射到要使用的角色。

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

    也可以映射为一个bean,由spring boot配置自动拾取。

    @EnableWebSecurity
    public class OAuth2LoginSecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                .oauth2Login(withDefaults());
        }
    
        @Bean
        public GrantedAuthoritiesMapper userAuthoritiesMapper() {
            ...
        }
    }
    

    您可以阅读更多关于它的信息here。

    【讨论】:

    • 谢谢@Toerktumlare!我会探索你推荐的。如果这行得通,我会回来接受你的回答。
    • @JimTough 成功了吗?
    • 对我不起作用。我放弃了这种 OAuth2 集成方法。
    • @JimTough 那么替代方案是什么?我正在第三天解决这个问题并变得绝望:)
    • @ligett - 对不起,程序员同事,但我放弃了这个 POC 项目,转而去做别的事情 :(
    猜你喜欢
    • 2017-01-31
    • 2015-04-07
    • 2017-10-27
    • 2020-05-26
    • 2018-02-13
    • 2012-06-11
    • 2021-02-07
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多