问题描述
我正在使用 Spring Boot 2.3.x、Spring Security 5 和 Thymeleaf 构建一个 Java 网络应用程序,在 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 相关的角色和权限。
问题
我现在有两种断开连接的用户:
我想要的最终状态是:
- 我的应用程序数据库存储分配给每个用户的角色/权限
- 应用程序将支持通过 HTTP Basic 或 OAuth2(最初到 Facebook)进行身份验证,但我的应用程序数据库将提供角色/权限
- 每个用户的“唯一标识符”将是他们的电子邮件地址(Facebook OAuth2 将此作为属性提供),因此我希望这可用于关联 HTTP Basic 和 OAuth2 身份验证对象
- 用户可以为其帐户设置 HTTP Basic 和 OAuth2,如果是这样,他们可以使用任一方法登录。无论哪种方式,他们在我的应用中仍将具有相同的角色/权限。
总结:我只想让 Facebook OAuth2 确认“这是一个活跃的 Facebook 用户,其电子邮件地址是 [email protected]”,然后将他们的 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.getAcce@R_502_6455@oken().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: [[email protected]]
[INFO ] AuthenticationEventLogger - principal type: OAuth2LoginAuthenticationToken | authorities: [ROLE_USER,ScopE_email,ScopE_public_profile]
权限 ROLE_USER,ScopE_public_profile
在我的申请中毫无意义。
解决方法
我相信您正在寻找的是 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。