SpringSecurity 灵活管控:特定用户单一设备登录机制

背景

突然接到一个需求

  1. 针对特定角色的人员登录web管理端,需要进行单一设备登录机制。
  2. 本来需求是禁止后面的人登录,但是这样有个问题,如果前一个人不登出,或直接关闭浏览器。后面人就登录不了了。只能等session过期。后来需求改成踢人
  3. 非管理端并不限制

初步考虑

听起来很简单,是不是SpringSecurity配置下就可以。但是实际情况确实,SpringSecurity配置的方式是全局,如这样配置,就是全局的了,无法区分权限

scala 复制代码
import org.springframework.context.annotation.Configuration;
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;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/token/**", "/user/**","/actuator/health","/actuator/prometheus","/health","/prometheus").permitAll()
                .antMatchers("/v2/api-docs","/swagger-resources").permitAll()
                .anyRequest().authenticated().and()
                .logout().permitAll()
                .and()
                .csrf().disable()
                // 注意,不能在这里控制会话登录,这里是全域生效。因为需求是只针对特定做控制,所以不能这里控制。
                .sessionManagement().maximumSessions(1)
                .maxSessionsPreventsLogin(false)
        ;
    }
}

然后又找了一种方法,但这种方法不适合我们,一是我们springboot版本低不支持,二是,这种方法很难做到分端控制

kotlin 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authorization.AuthenticatedPrincipal;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        AuthorizationManager<AuthenticatedPrincipal> isAdmin = (auth, context) -> {
            for (GrantedAuthority authority : auth.getAuthorities()) {
                if ("ROLE_ADMIN".equals(authority.getAuthority())) {
                    return new AuthorizationDecision(true);
                }
            }
            return new AuthorizationDecision(false);
        };

        http
            .sessionManagement(session -> session
                .maximumSessions((auth) -> {
                    if (isAdmin.check(auth, null).isGranted()) {
                        return -1; // 管理员可以有无限个会话
                    } else {
                        return 1; // 普通用户只能有一个会话
                    }
                })
            );
        return http.build();
    }
}

敲定方案

最终决定,管理端登录接口才限制、并且要区分权限来控制。登录应用只管踢人。所有其他应用集成jar,统一进行踢人处理。

上代码

非登录应用集成jar包

自定义登录控制器
scala 复制代码
package com.custom.config.filter;

import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.web.session.ConcurrentSessionFilter;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 *
 */

public class MyConcurrentSessionFilter extends ConcurrentSessionFilter {

    public MyConcurrentSessionFilter(SessionRegistry sessionRegistry, SessionInformationExpiredStrategy sessionInformationExpiredStrategy) {
        super(sessionRegistry, sessionInformationExpiredStrategy);
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;


        chain.doFilter(request, response);
    }
}
增加控制拦截器配置
scala 复制代码
package com.custom.config;

import com.custom.config.filter.MyConcurrentSessionFilter;
import org.springframework.context.annotation.Configuration;
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.web.session.*;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.security.SpringSessionBackedSessionRegistry;

import javax.annotation.Resource;

/**
 *
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private MyConcurrentSessionFilter concurrentSessionFilter;

    private FindByIndexNameSessionRepository sessionRepository;

    @Resource(name = "sessionInformationExpiredStrategy")
    private SessionInformationExpiredStrategy sessionInformationExpiredStrategy;

    /**
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        init();

        http
                .csrf().disable()
                //.requestMatcher(new OAuthRequestedMatcher())
                .authorizeRequests()
                .antMatchers("/**").permitAll()//允许所有访问,权限控制交由filter
                .anyRequest().authenticated().and()
                .addFilter(concurrentSessionFilter)
        ;
    }



    public void init() {
        sessionRepository = SpringBeanUtils.getBean(FindByIndexNameSessionRepository.class);
        concurrentSessionFilter = new MyConcurrentSessionFilter(new SpringSessionBackedSessionRegistry(sessionRepository), sessionInformationExpiredStrategy);
        
    }
}
踢人提示配置,要区分自然过期还是被踢掉,这个配置是必须的
java 复制代码
package com.custom.config;

import com.alibaba.fastjson.JSON;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

/**
 * 
 */
@Configuration
public class BaseConfig {
    @Bean
    SessionInformationExpiredStrategy sessionInformationExpiredStrategy() {
        return new SessionInformationExpiredStrategy() {
            @Override
            public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
                HttpServletResponse response = event.getResponse();
                ExecuteResult<String> result = new ExecuteResult<>();
                result.setData("416");
                result.setError("此账号已退出,当前账号已在其他电脑登陆,一个账号不允许在多台电脑登陆,为不影响您的使用请及时修改密码,或申请个人运营账号使用。");
                response.setContentType("application/json;charset=utf-8");
                response.setCharacterEncoding("UTF-8");
                response.setStatus(416);
                PrintWriter out = response.getWriter();
                out.write(JSON.toJSONString(result));
                out.flush();
                return ;
            }
        };
    }
}

登录应用改动

登录接口增加如下代码,这快可以灵活根据权限去控制是否增加踢人的逻辑
kotlin 复制代码
package com.custom.hsi.uap.controller;

import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 登录控制
 */
@RestController
@RequestMapping(value = "/user")
public class LoginController {


    @Autowired
    private AuthenticationManager myAuthenticationManager;//授权管理器

    /**
     * session并发控制器
     * */
    @Autowired
    CompositeSessionAuthenticationStrategy compositeSessionAuthenticationStrategy;

    /**
     *
     */
    @PostMapping(value = "/loginForS")
    public ExecuteResult<Void> loginForS(HttpServletRequest request, HttpServletResponse response) {
        ExecuteResult<Void> result = new ExecuteResult<>();
        // 其他代码
        UsernamePasswordAuthenticationToken authRequest = null;
        Authentication authentication = null;

        authRequest = new UsernamePasswordAuthenticationToken("userName", "password");
        authentication = myAuthenticationManager.authenticate(authRequest); //调用loadUserByUsername//findByLoginName
        try {
            // 会话并发控制,踢掉前面的登录
            // 这里后续需要优化,这个操作redis比较频繁需要优化
            compositeSessionAuthenticationStrategy.onAuthentication(authentication, request, response);
        } catch (SessionAuthenticationException e) {
            // 这段代码是针对禁止一个用户多次登陆使用的,踢人的逻辑不会用这个代码
            if (e.getMessage().contains("Maximum sessions of 1 for this principal")) {
                result.setError("此账号已在其他地点登陆,禁止同时登陆多个账号,若有登陆需求,请联系服务经理申请个人相应权限的登陆账号。");
                return result;
            }
            throw e;
        } catch (Exception e) {
            throw e;
        }
        // 其他代码
        return result;
    }

    @Data
    private final class ExecuteResult<T> {
        String error;
    }
}
登录应用增加配置,控制单一设备登录逻辑
java 复制代码
package com.custom.security.config;

import com.alibaba.fastjson.JSON;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.security.web.authentication.session.*;
import org.springframework.security.web.session.SessionInformationExpiredEvent;
import org.springframework.security.web.session.SessionInformationExpiredStrategy;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.security.SpringSessionBackedSessionRegistry;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.List;

/**
 * spring security配置类
 */
@Configuration
public class SecuritySessionConfig {

    @Bean
    SessionInformationExpiredStrategy sessionInformationExpiredStrategy() {
        return new SessionInformationExpiredStrategy() {
            @Override
            public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
                HttpServletResponse response = event.getResponse();
                ExecuteResult<String> result = new ExecuteResult<>();
                result.setData("416");
//                result.setError("您的账号在其他设备登录");
                result.setError("此账号已在其他地点登陆,禁止同时登陆多个账号,若有登陆需求,请联系服务经理申请个人相应权限的登陆账号。");
                response.setContentType("application/json;charset=utf-8");
                response.setCharacterEncoding("UTF-8");
                PrintWriter out = response.getWriter();
                out.write(JSON.toJSONString(result));
                out.flush();
                return ;
            }
        };
    }

    @Bean
    @DependsOn("userDetailsService")
    SpringSessionBackedSessionRegistry sessionRegistry(FindByIndexNameSessionRepository sessionRepository) {
        return new SpringSessionBackedSessionRegistry(sessionRepository);
    }

    /**
     * 会话并发控制,非全域生效,需要业务自行控制
     * */
    @Bean
    public CompositeSessionAuthenticationStrategy compositeSessionAuthenticationStrategy(RegisterSessionAuthenticationStrategy registerSessionAuthenticationStrategy, ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlAuthenticationStrategy, SessionFixationProtectionStrategy sessionFixationProtectionStrategy) {

        List<SessionAuthenticationStrategy> list = new ArrayList<>();
        list.add(registerSessionAuthenticationStrategy);
        list.add(sessionFixationProtectionStrategy);
        list.add(concurrentSessionControlAuthenticationStrategy);
        return new CompositeSessionAuthenticationStrategy(list);
    }

    @Bean
    public RegisterSessionAuthenticationStrategy registerSessionAuthenticationStrategy(SpringSessionBackedSessionRegistry sessionRegistry) {
        return new RegisterSessionAuthenticationStrategy(sessionRegistry);
    }

    @Bean
    public SessionFixationProtectionStrategy sessionFixationProtectionStrategy() {
        return new SessionFixationProtectionStrategy();
    }

    /**
     * 在这里控制会话登录,这里是不是全域生效。需要在具体生效的代码中,调用这个bean
     * */
    @Bean
    public ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlAuthenticationStrategy(SpringSessionBackedSessionRegistry sessionRegistry) {
        ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlAuthenticationStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry);
        concurrentSessionControlAuthenticationStrategy.setMaximumSessions(1);
        // 这个属性控制,是禁止登录还是踢掉之前登录的人,true 禁止登录,false 踢人
        concurrentSessionControlAuthenticationStrategy
                .setExceptionIfMaximumExceeded(false);
        return concurrentSessionControlAuthenticationStrategy;
    }

    @Data
    private final class ExecuteResult<T> {
        String data;
        String error;
    }
}

总结

登录的时候,针对特定用户,进行踢人动作。springsecurity会针对该用户的session进行打标,然后MyConcurrentSessionFilter会针对打标的请求进行特定的返回,并让session失效

相关推荐
superman超哥4 分钟前
Rust String与&str的内部实现差异:所有权与借用的典型案例
开发语言·后端·rust·rust string·string与str·内部实现·所有权与借用
愈努力俞幸运28 分钟前
rust安装
开发语言·后端·rust
踏浪无痕33 分钟前
JobFlow 负载感知调度:把任务分给最闲的机器
后端·架构·开源
UrbanJazzerati35 分钟前
Python自动化统计工具实战:Python批量分析Salesforce DML操作与错误处理
后端·面试
我爱娃哈哈44 分钟前
SpringBoot + Seata + Nacos:分布式事务落地实战,订单-库存一致性全解析
spring boot·分布式·后端
nil1 小时前
记录protoc生成代码将optional改成omitepty问题
后端·go·protobuf
superman超哥1 小时前
Rust 范围模式(Range Patterns):边界检查的优雅表达
开发语言·后端·rust·编程语言·rust范围模式·range patterns·边界检查
云上凯歌2 小时前
02 Spring Boot企业级配置详解
android·spring boot·后端
秋饼2 小时前
【手撕 @EnableAsync:揭秘 SpringBoot @Enable 注解的魔法开关】
java·spring boot·后端
IT_陈寒2 小时前
Python 3.12 新特性实战:这5个改进让我的开发效率提升40%
前端·人工智能·后端