• 首页

  • 归档
小 黑 捡 方 块
小 黑 捡 方 块

小黑捡方块

获取中...

07
17
spring security

记一次spring boot security登录认证session管理问题排查的恶心经历

发表于 2019-07-17 • 被 334 人看爆

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重写可完美解决问题。

分享到:
git备忘
Springboot+Security UserDetailsService里面抛出UsernameNotFoundException异常无法在failureHandler中截获
  • 文章目录
  • 站点概览
小黑捡方块

帅哥小黑捡方块

Github Email RSS
看爆 Top5
  • jenkins通过ssh连接失败:Failed to add SSH key. Message [invalid privatekey 1,822次看爆
  • docker运行的jenkins在构建项目并打包成docker镜像时问题总结 1,657次看爆
  • ConcurrentHashMap核心部分源码注释 1,369次看爆
  • docker启动后外网和主机死活访问不到springboot项目 993次看爆
  • Springboot+Nginx+certbot重定向400错误解决 882次看爆
蜀ICP备19040029号

Copyright © 2021 小黑捡方块 ·

Proudly published with Halo · Theme by fyang · 站点地图