简体   繁体   English

Spring Boot 2.3 和 Spring 安全性 5 - 在一个模式中支持 UserDetailsService 和 OAuth2

[英]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. 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 Thompson(又名 Spring 框架大师)在他的“ Spring 安全核心:初学者到大师”课程中使用的方法。 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. John's approach uses Spring Data JPA and HTTP Basic authentication, where I implement the Spring interface UserDetailsService ervice 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')") .因为我将每个用户的角色/权限存储在我的数据库中,所以我可以完全控制并且可以将它们与 Spring 安全方法级注释一起使用,如下所示: @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.约翰在课程材料中的方法的问题在于我仅限于 HTTP Basic 和存储/管理所有用户密码。

Yesterday I experimented with the OAuth 2.0 features of Spring Security 5 to 'login with Facebook'.昨天我试验了 Spring Security 5 的 OAuth 2.0 功能来“使用 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.单独来看,这很适合我的应用程序将用户身份验证为 Facebook 成员。 Unfortunately, this supplies a different kind of @AuthenticationPrincipal object that contains roles and authorities that are only relevant to Facebook.不幸的是,这提供了一种不同类型的@AuthenticationPrincipal object,其中包含仅与 Facebook 相关的角色和权限。

THE PROBLEM问题

I now have two disconnected types of users:我现在有两种断开连接的用户类型:

  1. HTTP Basic authenticated users, whose credentials, roles and authorities I manage. HTTP 基本身份验证用户,我管理其凭据、角色和权限。 Their roles/authorites are appropriate for my application.他们的角色/权限适合我的应用程序。
  2. OAuth2 authenticated users, whose credentials, roles and authorities are out of my control. OAuth2 身份验证用户,其凭据、角色和权限不受我控制。 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该应用程序将支持通过 HTTP Basic 或 OAuth2 进行身份验证(最初到 Facebook),但我的应用程序数据库将提供角色/权限
  • 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每个用户的“唯一标识符”将是他们的 email 地址(Facebook OAuth2 将其作为属性提供),所以我希望这可用于关联 HTTP 基本和 OAuth2 身份验证对象
  • A user could setup both HTTP Basic and OAuth2 for their account, and if so, they can login with either method.用户可以为其帐户设置 HTTP Basic 和 OAuth2,如果是这样,他们可以使用任一方法登录。 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. 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). Spring 在为每个单独的用例(HTTP Basic 与 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.正如我上面提到的,与UserDetailsService ervice 相关的部分和为该服务提供服务的 JPA 实体已经运行良好。 My question is essentially 'How do I incorporate OAuth2 into what already works?'.我的问题本质上是“如何将 OAuth2 合并到已经工作的内容中?”。

My WebSecurityConfigurerAdapter implementation class我的WebSecurityConfigurerAdapter实现 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 Controller 我的登录表单支持“使用 Facebook 登录”或 HTTP 基本

@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)我的UserDetailsService ervice 实现 class(用于 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")
        );
    }

}

My UserRepository definition我的UserRepository定义

public interface UserRepository extends JpaRepository<User, Integer> {

    Optional<User> findByUsername(String username);

}

My User entity我的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 - Creates a table used by the OAuth2 classes in Spring Security for persistent token storage schema.sql - 创建由 Spring 中的 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)
);

The log output from my LoginFormController looks like this when I login via Facebook OAuth2 using my own Facebook account:当我使用我自己的 Facebook 帐户通过 Facebook OAuth2 登录时,来自我的LoginFormController的日志 output 看起来像这样:

[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:我还从我的另一个监听器 class 看到了这个日志 output:

[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.权限ROLE_USER, SCOPE_email, SCOPE_public_profile在我的应用程序上下文中毫无意义。


I believe what you are looking for is the GrantedAuthoritiesMapper我相信您正在寻找的是GrantedAuthoritiesMapper

You register a bean that will map your authorities to roles to be used.您注册一个 bean,它将 map 您的权限授予要使用的角色。

@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.它也可以映射为 bean 并由 spring 引导配置自动拾取。

@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 .你可以在这里阅读更多关于它的信息。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

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