写在前面 在前一篇《自动踢掉登录用户》一文中,我们采用的是前后端不分离模式,但是在前后端分离盛行的当下,有必要对此模式下的自动踢掉用户进行学习。同时在前一文中,用户信息都是配置在内存中,而实际工作中都是将其放入数据库中,因此需要切换数据源为数据库。需要注意的是,在使用SpringSecurity中的Session做并发处理时,直接将内存中的用户切换为数据库中的用户,也就是将内存源切换为数据库源是会出现问题的,接下来就来细说这个问题。
前后端分离项目实例化 考虑到此处主要学习如何在前后端分离模式下,实现自动踢掉登录用户,因此就只是单纯的将用户存储在数据库中,而不进行任何的权限控制。同时此处登录使用JSON格式。
第一步 ,使用IDEA创建一个名为kickoffuser-json
的SpringBoot工程,并在其pom.xml依赖文件中添加如下依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> </dependencies>
第二步 ,新建entity包,并在其中创建用户类User,里面代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @Data @Entity(name = "t_user") public class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // @Getter(value = AccessLevel.NONE) private String username; // @Getter(value = AccessLevel.NONE) private String password; @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } @Override public String getPassword() { return password; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
第三步 ,新建repository包,并在里面新建一个UserRepository
接口,里面的代码如下:
1 2 3 public interface UserRepository extends JpaRepository<User,Long> { User findUserByUsername(String username); }
第四步 ,新建service包,并在里面新建一个MyUserDetailService
类,里面的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Service public class MyUserDetailService implements UserDetailsService { @Autowired private UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user= userRepository.findUserByUsername(username); if(user==null){ throw new UsernameNotFoundException("用户不存在"); } return user; } }
第五步 ,新建controller包,并在里面新建一个HelloController
类,里面的代码如下:
1 2 3 4 5 6 7 @RestController public class HelloController { @GetMapping("/hello") public String hello(){ return "hello,world!"; } }
第六步 ,新建filter包,并在里面新建一个EnvyLoginFilter
类,里面的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @Component public class EnvyLoginFilter extends UsernamePasswordAuthenticationFilter { @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if(!request.getMethod().equals("POST")){ throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); }else{ //判断请求类型 if(request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE) ||request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)){ //说明此时使用的JSON方式 Map<String,String> loginData = new HashMap<>(); try{ loginData = new ObjectMapper().readValue(request.getInputStream(),Map.class); }catch (IOException e){ e.printStackTrace(); } String username =loginData.get(getUsernameParameter()); String password =loginData.get(getPasswordParameter()); username = username != null ?username.trim():""; password = password != null ?password:""; UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username,password); this.setDetails(request,authenticationToken); return this.getAuthenticationManager().authenticate(authenticationToken); }else { //说明此时使用的是Key/Value键值对方式 return super.attemptAuthentication(request,response); } } } }
第七步 ,新建config包,并在里面新建一个SecurityConfig
类,里面的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyUserDetailService myUserDetailService; @Bean PasswordEncoder passwordEncoder(){ return NoOpPasswordEncoder.getInstance(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(myUserDetailService); } @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/js/**", "/css/**","/login.html"); } @Bean public EnvyLoginFilter envyLoginFilter() throws Exception { EnvyLoginFilter envyLoginFilter = new EnvyLoginFilter(); //设置登录成功时的逻辑 envyLoginFilter.setAuthenticationSuccessHandler((httpServletRequest,httpServletResponse,authentication)->{ httpServletResponse.setContentType("application/json;charset=utf-8"); PrintWriter out = httpServletResponse.getWriter(); Map<String,Object> map = new HashMap<>(); User user = (User) authentication.getPrincipal(); user.setPassword(null); map.put("status",200); map.put("user",user); out.write(new ObjectMapper().writeValueAsString(map)); out.flush(); out.close(); }); envyLoginFilter.setAuthenticationFailureHandler((httpServletRequest,httpServletResponse,exception)->{ httpServletResponse.setContentType("application/json;charset=utf-8"); PrintWriter out = httpServletResponse.getWriter(); Map<String,Object> map = new HashMap<>(); map.put("status",401); if (exception instanceof LockedException) { map.put("msg", "账户被锁定,请联系管理员!"); } else if (exception instanceof BadCredentialsException) { map.put("msg", "账户名或密码输入错误,请重新输入!"); } else if (exception instanceof DisabledException) { map.put("msg", "账户被禁用,请联系管理员!"); } else if (exception instanceof AccountExpiredException) { map.put("msg", "账户已过期,请联系管理员!"); } else if (exception instanceof CredentialsExpiredException) { map.put("msg", "密码已过期,请联系管理员!"); } else { map.put("msg", "登录失败!"); } out.write(new ObjectMapper().writeValueAsString(map)); out.flush(); out.close(); }); envyLoginFilter.setAuthenticationManager(authenticationManagerBean()); envyLoginFilter.setFilterProcessesUrl("/goLogin"); return envyLoginFilter; } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests().anyRequest().authenticated() .and() .formLogin() .loginPage("/login.html") .loginProcessingUrl("/goLogin") .permitAll() .and() .csrf().disable(); http.addFilterAt(envyLoginFilter(), UsernamePasswordAuthenticationFilter.class); } }
第八步 ,数据库和JPA配置。修改application.yml
配置文件,在里面新增如下配置信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 spring: datasource: driver-class-name: com.mysql.jdbc.Driver username: root password: envy123 url: jdbc:mysql:///kickuser?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai jpa: database: mysql database-platform: mysql hibernate: ddl-auto: update show-sql: true properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect main: allow-bean-definition-overriding: true
第九步 ,启动项目进行测试。请注意,由于此处EnvyLoginFilter实例仅仅配置了使用JSON格式登录成功和失败时的处理逻辑,因此当开发者使用FORM表单登录提交时的成功与否均无法处理,这一点很容易出错,需要开发者引起高度注意。
可以看到初始化项目仅仅是实现了使用数据库保存用户,同时用户采用JSON格式登录这些功能,并未实现本篇介绍的自动踢掉登录用户这一功能,那么接下来就在此基础上实现本篇功能。
问题分析 通过前一篇《JPA+自动踢掉登录用户》一文的学习,我们知道在前后端不分离模式下,采用数据库保存用户,那么需要将自定义的User类重写equals
和hashCode
方法,之后按照之前的方式配置即可。
但是你会发现上述配置似乎是无效的,原因在于此处我们采用的是前后端分离模式下的,基于数据库保存用户的认证,同时使用自定义的过滤器替代了默认的UsernamePasswordAuthenticationFilter
,这样导致前一篇所介绍的基于session的配置都失效了。因此所有相关的配置我们应当在自定义的EnvyLoginFilter
中进行配置,包括SessionAuthenticationStrategy
等亦是如此。
可能需要修改甚至增加一些代码逻辑,但是这对于理解SpringSecurity是有一定帮助的。
问题解决 接下来就在本篇第一部分“前后端分离项目初始化”基础上对代码进行修改和增加,以实现自动踢掉登录用户这一功能。
第一步 ,重写自定义用户类User的equals()
和hashCode()
方法:
1 2 3 4 5 6 7 8 9 10 @Data @Entity(name = "t_user") public class User implements UserDetails { ...... public boolean equals(Object rhs) { return rhs instanceof User ? this.username.equals(((User)rhs).username) : false; } public int hashCode() { return this.username.hashCode();
第二步 ,新建bean包,并在里面新建一个响应状态类ResponseBean
,里面的代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 public class ResponseBean { private Integer status; private String message; private Object object; private ResponseBean() { } private ResponseBean(Integer status, String message, Object object) { this.status = status; this.message = message; this.object = object; } public static ResponseBean build(){ return new ResponseBean(); } //成功,只有信息 public static ResponseBean ok(String message){ return new ResponseBean(200,message,null); } //成功,有信息和数据 public static ResponseBean ok(String message,Object object){ return new ResponseBean(200,message,object); } //失败,只有信息 public static ResponseBean error(String message){ return new ResponseBean(500,message,null); } //失败,有信息和数据 public static ResponseBean error(String message,Object object){ return new ResponseBean(500,message,object); } public Integer getStatus() { return status; } public ResponseBean setStatus(Integer status) { this.status = status; return this; } public String getMessage() { return message; } public ResponseBean setMessage(String message) { this.message = message; return this; } public Object getObject() { return object; } public ResponseBean setObject(Object object) { this.object = object; return this; } }
第三步 ,回到自定义的SecurityConfig类中。前面说过我们需要提供一个SessionRegistryImpl
的实例,之后由于处理session并发的是ConcurrentSessionControlAuthenticationStrategy
,因此我们还需要提供一个ConcurrentSessionControlAuthenticationStrategy
实例,然后设置它的处理并发数。
首先进行第一步,在SecurityConfig
类中提供一个SessionRegistryImpl
实例:
1 2 3 4 @Bean SessionRegistryImpl sessionRegistry(){ return new SessionRegistryImpl(); }
之后在SecurityConfig#envyLoginFilter()
方法中提供一个ConcurrentSessionControlAuthenticationStrategy
实例,并进行对应设置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Bean public EnvyLoginFilter envyLoginFilter() throws Exception { EnvyLoginFilter envyLoginFilter = new EnvyLoginFilter(); //设置登录成功时的逻辑 envyLoginFilter.setAuthenticationSuccessHandler( //逻辑省略 ) //设置登录失败时的逻辑 envyLoginFilter.setAuthenticationFailureHandler( //逻辑省略 ) envyLoginFilter.setAuthenticationManager(authenticationManagerBean()); envyLoginFilter.setFilterProcessesUrl("/goLogin"); ConcurrentSessionControlAuthenticationStrategy sessionStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry()); sessionStrategy.setMaximumSessions(1); envyLoginFilter.setSessionAuthenticationStrategy(sessionStrategy); return envyLoginFilter; }
可以看到这里我们传入一个SessionRegistryImpl
对象来实例化ConcurrentSessionControlAuthenticationStrategy
对象,之后设置session并发数为1,最后再将其配置给自定义的envyLoginFilter对象。
可能你会好奇为什么需要配置这些,而在前一篇文章中什么都没做,原因在于使用默认的类,系统都自动配置好了,而自定义类并没有这些功能,因此需要开发者自己来手动配置。
还没有完,第四步 ,处理session时还需要使用到一个关键的名为ConcurrentSessionFilter
的过滤器,本来这个过滤器开发者是不用修改的,但是这个过滤器中使用到了SessionRegistryImpl
,而我们已经自定义了SessionRegistryImpl
,因此就需要重新配置该过滤器。修改SecurityConfig#configure(HttpSecurity http)
方法中的代码为如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests().anyRequest().authenticated() .and() .formLogin() .loginPage("/login.html") .loginProcessingUrl("/goLogin") .permitAll() .and() .csrf().disable(); http.addFilterAt(new ConcurrentSessionFilter(sessionRegistry(),event->{ HttpServletResponse response = event.getResponse(); response.setContentType("application/json;charset=utf-8"); response.setStatus(401); PrintWriter out = response.getWriter(); out.write(new ObjectMapper().writeValueAsString(ResponseBean.error("您已在另一台设备登录,本次登录已下线!"))); out.flush(); out.close(); }),ConcurrentSessionFilter.class); http.addFilterAt(envyLoginFilter(), UsernamePasswordAuthenticationFilter.class); }
可以看到我们使用自定义的ConcurrentSessionFilter
过滤器替代了系统默认的ConcurrentSessionFilter
过滤器,只不过没起名字而已。同时查看这个ConcurrentSessionFilter
的构造方法可知,该方法需要SessionRegistry
和SessionInformationExpiredStrategy
这两个参数:
1 2 3 4 5 6 public ConcurrentSessionFilter(SessionRegistry sessionRegistry, SessionInformationExpiredStrategy sessionInformationExpiredStrategy) { Assert.notNull(sessionRegistry, "sessionRegistry required"); Assert.notNull(sessionInformationExpiredStrategy, "sessionInformationExpiredStrategy cannot be null"); this.sessionRegistry = sessionRegistry; this.sessionInformationExpiredStrategy = sessionInformationExpiredStrategy; }
因此这里的SessionRegistry
我们就使用了前面配置的SessionRegistry
实例,而SessionInformationExpiredStrategy
对象则是通过一个lambda表达式来创建该对象。其实这个SessionInformationExpiredStrategy
对象就是处理session过期后的回调函数,也就是当用户在被另一个登录用户踢下线之后,开发者想给该用户什么下线提示,此时就可以在这个方法内完成。
你以为这就完了么?不是的,还有最后一步 ,需要将用户登录的session信息进行保存,因此需要回到EnvyLoginFilter#EnvyLoginFilter()
方法中,修改里面的内容如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if(!request.getMethod().equals("POST")){ throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); }else{ //判断请求类型 if(request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE) ||request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)){ //说明此时使用的JSON方式 Map<String,String> loginData = new HashMap<>(); try{ loginData = new ObjectMapper().readValue(request.getInputStream(),Map.class); }catch (IOException e){ e.printStackTrace(); } String username =loginData.get(getUsernameParameter()); String password =loginData.get(getPasswordParameter()); username = username != null ?username.trim():""; password = password != null ?password:""; UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username,password); this.setDetails(request,authenticationToken); User user = new User(); user.setUsername(username); sessionRegistry.registerNewSession(request.getSession(true).getId(),user); return this.getAuthenticationManager().authenticate(authenticationToken); }else { //说明此时使用的是Key/Value键值对方式 return super.attemptAuthentication(request,response); } } }
其实就是新增了如下内容:
1 2 3 4 //新增内容 User user = new User(); user.setUsername(username); sessionRegistry.registerNewSession(request.getSession(true).getId(),user);
这里我们重新实例化了一个User,并设置该name属性为当前登录用户,之后手动调用sessionRegistry.registerNewSession(request.getSession(true).getId(),user);
方法来保存当前用户登录的session信息。
在完成上述配置后,接下来就可以进行多端测试,可以发现当用户被人踢下线时,系统会有您已在另一台设备登录,本次登录已下线!
这一提示信息。
那么本篇关于前后端分离模式下的,基于数据库保存用户,实现自定踢掉登录用户的学习就到此为止,后续学习其他内容。