[英]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 相关的角色和权限。
I now have two disconnected types of users:我现在有两种断开连接的用户类型:
The end-state I want is:我想要的最终状态是:
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.
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.我只是不知道从哪里开始。
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() {
...
}
}
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.