1.WebSecurityConfigurerAdapter配置
import com.anjr.manager.auth.handler.ManagerAuthenticationFailureHandler;
import com.anjr.manager.auth.handler.ManagerAuthenticationSuccessHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.session.HttpSessionEventPublisher;
/**
* @author Seichii.wei
* @date 2019-07-15 11:03:38
* 管理后台登录授权认证配置
*/
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableWebSecurity
@Order(SecurityProperties.BASIC_AUTH_ORDER)
public class SecurityConfigurationManager extends WebSecurityConfigurerAdapter {
/**
* 登录页面,其实没有真正的页面,这个是提示登录返回结果
*/
private static final String LOGIN_PAGE_URL = "/manager/admin/login_p";
/**
* 登录链接,交给spring security管理
*/
private static final String LOGIN_URL = "/manager/admin/login";
@Autowired
private AuthenticationProviderManager authProvider;
@Autowired
private ManagerAuthenticationFailureHandler authenticationFailureHandler;
@Autowired
private ManagerAuthenticationSuccessHandler authenticationSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors()
.and().csrf().disable()
.authenticationProvider(authProvider)
.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.formLogin()
.loginPage(LOGIN_PAGE_URL)
.loginProcessingUrl(LOGIN_URL)
.failureHandler(authenticationFailureHandler)
.successHandler(authenticationSuccessHandler)
.permitAll()
.and()
.exceptionHandling()
// .authenticationEntryPoint((request, response, authException) -> response.getWriter().write(new ResponseBean().error(AdminError.ADMIN_ACCESS_DENIED_ERROR, authException.getMessage()).toString()))
// 没有权限处理器
.accessDeniedHandler(accessDeniedHandler())
.and()
.sessionManagement()
.invalidSessionUrl(LOGIN_PAGE_URL)
// 同一用户最大session数
.maximumSessions(1)
// 达到最大数不禁止登录,第二个会挤掉前一个;若设置为true,则第二个人被禁止登录
.maxSessionsPreventsLogin(false)
.expiredUrl(LOGIN_PAGE_URL)
.sessionRegistry(sessionRegistry());
}
@Bean
public AccessDeniedHandler accessDeniedHandler() {
return new AccessDeniedManagerHandler();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected UserDetailsService userDetailsService() {
return new UserDetailsManagerServiceImpl();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
}
2.AuthenticationProvider代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
/**
* @author Seichii.wei
* @date 2019-07-15 14:20:56
* 登录认证功能
*/
@Component
public class AuthenticationProviderManager implements AuthenticationProvider {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
UserDetails userDetails = null;
if (username != null) {
userDetails = userDetailsService.loadUserByUsername(username);
}
// userDetails不会为空
// 密码校验
//与authentication里面的credentials相比较
if (!passwordEncoder.matches(authentication.getCredentials().toString(), userDetails.getPassword())) {
throw new BadCredentialsException("用户名或密码错误");
}
// 用户状态校验
if (!userDetails.isEnabled()) {
throw new DisabledException("账号已被禁用");
} else if (!userDetails.isAccountNonExpired()) {
throw new AccountExpiredException("账号已过期");
} else if (!userDetails.isAccountNonLocked()) {
throw new LockedException("账号已被锁定");
} else if (!userDetails.isCredentialsNonExpired()) {
throw new CredentialsExpiredException("凭证已过期");
}
// UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
// token.setDetails(userDetails);
// return token;
return new UsernamePasswordAuthenticationToken(userDetails, authentication.getCredentials(), userDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
3.预期结果:当在浏览器中登录用户A后,在postman中登录用户A,查看sessionRegistry.getAllPrincipals()
结果应该是1,然而事实是2
4.调试经历
debugsessionRegistry.getAllSessions
发现在通过concurrentHashmap获取sessions时,明明key是同一个UserDetails对象,却get不到结果。【线索1:可能跟hash值有关】
怀疑AuthenticationProvider返回的UsernamePasswordAuthenticationToken因为是new出来的,导致get不到结果,改为注释中内容:
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
token.setDetails(userDetails);
return token;
果然预期目的达到了。但是引发了另一个问题,每次请求接口(比如写了个test接口)都会进入到AuthenticationProvider中去认证,增加token.setAuthenticated(true);
后会报错。【线索2,确实因为UserDetails对象改变导致无法获取到session】
5.尝试解决
1.在UserDetails中增加@EqualsAndHashCode
测试仍然不对。
2.猜测是否跟用户名有关,手动重写equals和hashcode方法:
@Override
public String toString() {
return this.admin.getUsername();
}
@Override
public int hashCode() {
return this.admin.getUsername().hashCode();
}
@Override
public boolean equals(Object obj) {
return this.toString().equals(obj.toString());
}
测试结果达到预期!完美解决!
6.研究原理
1.重写UserDetails的hashcode和equals方法,debug发现两次登录的hash值是不一样的,对比两次的UserDetails对象发现是因为用户对象admin的updateTime字段值发生了变化导致的。
2.将UserDetailsService中从数据库查询到的admin对象updateTime设为null,再次debug发现两次登录的hash值一样了,但是getAllSessions()时hash值发生了两次变化,也就是存在了3个不同的hash值,对比用户对象发现登录确实没有updateTime值了,但是getAllSessions()时有updateTime值,问题出在登录成功的successHandler中直接从authentication中getAdmin并更新updateTime导致的如下:
import com.anjr.manager.auth.bean.ManagerUserDetails;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.seichiiwei.common.utils.ResponseBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
/**
* @author Seichii.wei
* @date 2019-07-16 16:20:46
* 登录成功处理器
*/
@Component
public class ManagerAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
ManagerUserDetails userDetails = (ManagerUserDetails) authentication.getPrincipal();
// 更新登录时间,其实这里写错了应该是setLastLoginTime
userDetails.getAdmin().setUpdateTime(LocalDateTime.now()).updateById();
response.getWriter().write(objectMapper.writeValueAsString(ResponseBuilder.buildOk(userDetails)));
}
}
修改后解决问题:
import com.anjr.manager.auth.bean.ManagerUserDetails;
import com.anjr.manager.entity.Admin;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.seichiiwei.common.utils.ResponseBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
/**
* @author Seichii.wei
* @date 2019-07-16 16:20:46
* 登录成功处理器
*/
@Component
public class ManagerAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
ManagerUserDetails userDetails = (ManagerUserDetails) authentication.getPrincipal();
// 更新登录时间
new Admin().setId(userDetails.getAdmin().getId()).setUpdateTime(LocalDateTime.now()).updateById();
response.getWriter().write(objectMapper.writeValueAsString(ResponseBuilder.buildOk(userDetails)));
}
}
3.结论
由于Spring Security中session管理是通过ConcurrentHashMap实现的,那么取值的时候是通过hash值来寻址,所以要保证同一session对象的hash值不能发生变化,于是当UserDetails需要发生变化时就必须重写equals和hashCode方法,且不能对会发生变化的字段做处理(如上述updateTime),否则多次登录得到的UserDetails对象都不一样(hash值不同)从而导致明明是同一用户sessionRegistry.getAllPrincipals()却得到多个用户登录。因此使用username进行hashCode重写可完美解决问题。