Spring Security 单点登出


oauth 2.0 服务端可以参考文章: Spring Security oauth2.0 服务端
 
oauth 2.0 单点登录可以参考文章: Spring Security 单点登录
 

背景

单点登出
如何理解单点登出呢?
举例说明:A、B、C 三个系统已登录,若干时间后,A系统主动登出,触发 B、C系统被动登出,即一个系统登出,所有系统也登出

TOKEN
业务系统判断登出的关键参数,就是 TOKEN,而 TOKEN 一般有两种方式,一种是不可逆加密字符串,一种是JWT
这两种 TOKEN 分别如何校验其合法性呢?

  1. 加密字符串:调用 sso 服务的 /check/token 接口
  2. 根据 JWT 的 key,用算数的方式判断,详情可参考 Spring Security oauth2.0 客户端

 

方案1

第一种方案的单点登出方式比较简单:

  1. A系统主动登出,调用 sso 的登出接口 /logout
  2. sso 使 token 失效
  3. B、C 系统校验 token,即调用 sso 的 /check/token 接口
  4. B、C 系统得知 token 已失效,实现单点登出

然而实际项目中,一般不会使用这种方案,为什么呢?
因为业务系统的每个请求都访问一次 sso 的 /check/token 接口,会导致 sso 的 qps 非常之大,不符合实际情况,而 JWT 就是为解决这种场景应运而生的

 

方案2

JWT的单点登出方案就略为复杂了,因为 A系统登出后,虽然可以通知 sso 使 token 失效,但 B、C系统是基于 JWT 算数方式的来判断 token 有效性的,所以无法使 B、C系统登出

解决方案

  1. A 系统主动登出,请求 sso 的登出接口 /logout
  2. sso 发送 kafka 消息,广播通知 B、C 系统需要登出
  3. B、C 系统收到消息后,使 token 失效

  

/logout
推送消息
分发消息
分发消息
分发消息
A系统登出
sso
kafka
B 系统
C 系统
D 系统

pom 文件添加 kafka 依赖

<!-- kafka -->
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

 
application 添加 kafka 配置

spring:
  kafka:
    bootstrap-servers: 192.168.100.1:9092

 
登出处理器 LogoutSuccessHandler

  • 引入 KafkaTemplate
  • kafka 向登出 topic 发送用户 userName
  • 执行父类的 handle 函数
@Slf4j
@Component
public class AuthLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {

    @Autowired
    KafkaTemplate kafkaTemplate;

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        if (!Objects.isNull(authentication)) {
            String userName = authentication.getName();

            try {
                JSONObject obj = new JSONObject();
                obj.put("userName", userName);
                String msg = obj.toJSONString();
                kafkaTemplate.send("logout", msg);
                log.info("登出发送kafka成功,msg:{}", msg);
            } catch (RuntimeException e) {
                log.error("登出成功后,发送kafka报错:{}", e);
            }
        }
        super.handle(request, response, authentication);
    }
}

 
WebSecurityConfigurerAdapter 添加 LogoutSuccessHandler

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Resource
    private AuthLogoutSuccessHandler logoutSuccessHandler;

	...
  
    /**
     * HTTP请求安全处理
     *
     * @param http 不需要鉴权的请求可以在这里配置
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
                .formLogin()
                .loginPage("/login")
                .permitAll()

                .and()
                .authorizeRequests()
                .antMatchers("/test/**", "/oauth/**", "/login").permitAll()
                .antMatchers("/assets/**", "/css/**", "/images/**").permitAll()
                .anyRequest()
                .authenticated()

                .and()
                .csrf()
                .disable()
                .cors()

                .and()
                .logout()
                .logoutSuccessHandler(logoutSuccessHandler)
        ;
    }
    
    ...
}

 
业务系统收到 kafka 推送后,自行使 token 失效

 

前端同时登出

由于后台处理 token 失效后,未能即时通知前端,所以只有等用户操作时,才能使系统登出。要实现前端单点登出,有两种方案

  1. 业务系统前端轮询,调用一个可以查询 token 是否有效的接口,返回失败时实现登出
  2. 使用 websocket 长连接,后端收到 token 失效时,通知前端

第一种方案比较直白,容易实现,这里暂不做讨论,下面描述下第二种方案
 
pom 文件添加依赖

<!-- websocket -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

 
websocket 实现类

  • ServerEndpoint 注解:定义 websocket 连接名,调用此接口时,开启 websocket 连接,即调用 onOpen 方法
  • onOpen:开启连接
  • onClose:关闭连接
  • onMessage:接收对方发送的消息
  • sendMessage:向所有连接此后台的另一端发送消息,即向前端发送消息
@Component
@ServerEndpoint("/websocket")
@Slf4j
@EqualsAndHashCode
public class WebSocket {

    private Session session;

    private static CopyOnWriteArraySet<WebSocket> webSocketSet=new CopyOnWriteArraySet<>();

    @OnOpen
    public void onOpen(Session session){
        this.session=session;
        webSocketSet.add(this);
        log.info("【websocket消息】 有新的连接,总数:{}",webSocketSet.size());
    }

    @OnClose
    public void onClose(){
        webSocketSet.remove(this);
        log.info("【websocket消息】 连接断开,总数:{}",webSocketSet.size());
    }

    @OnMessage
    public void onMessage(String message){
        log.info("【websocket消息】 收到客户端发来的消息:{}",message);
    }

    public void sendMessage(String message){
        for(WebSocket webSocket:webSocketSet){
            log.info("【websocket消息】 广播消息,message={}",message);
            try {
                webSocket.session.getBasicRemote().sendText(message);

            }catch (Exception e){
                log.error("【websocket消息】异常:{}", e);
                throw ErrorExceptionEnum.UNKNOWN.getValue();
            }
        }
    }
}

 
业务系统后端接收 kafka 推送的登出 topic 后,websocket 发送消息到业务系统前端

@KafkaListener(topics = "#{'${spring.kafka.topic.logout}'}",groupId = "#{'${spring.kafka.groupId.logout}'}")
public void LogoutConsumer(ConsumerRecord<String, String> record) {
    Optional<String> kafkaMessage = Optional.ofNullable(record.value());
    kafkaMessage.ifPresent(msg -> {
        JSONObject obj = JSONObject.parseObject(msg);
        String userName = obj.getString("userName");

        //发送websocket消息(重点)
        webSocket.sendMessage(userName);           
    });
}

 
前端 配置 websocket 连接,修改 App.vue 文件

  • 在 created 生命周期建立 websocket 连接
  • 根据 env 配置不同环境的域名
<template>
  <div id="app">
    <router-view />
  </div>
</template>

<script>
export default {
  name: "App",
  watch: {
    "$store.getters.name": {
      deep: true,
      immediate: true,
      handler: function (val, oldVal) {
        if (val != "" && oldVal != "" && val != oldVal) {
          location.reload();
        }
      },
    },
  },
  data() {
    return {
      path: "",
      socket: "",
    };
  },
  mounted() {
    window.onbeforeunload = (e) => {
      if (
        location.pathname != "/401" &&
        location.pathname != "/404" &&
        location.pathname != "/app" &&
        location.pathname != "/"
      ) {
        sessionStorage.setItem("refresh", location.pathname);
      }
    };
  },
  created() {
    this.init();
  },
  methods: {
    init() {
      if (typeof WebSocket === "undefined") {
        alert("您的浏览器不支持webSocket,将体验不到系统部分功能!");
      } else {
        // 实例化socket
        this.socket = new WebSocket(
          `${
            process.env.VUE_APP_WebSocket
          }external/websocket`
          //   `ws://192.168.122.70:8888/external/webSocket`
        );
        // 监听socket连接
        this.socket.onopen = this.open;
        // 监听socket错误信息
        this.socket.onerror = this.error;
        // 监听socket消息
        this.socket.onmessage = this.getMessage;
      }
    },
    open() {
      console.log("socket连接成功");
    },
    error() {
      console.log("连接错误");
    },
    getMessage(msg) {
      console.log("接收消息 :>> ", msg.data);
      if (msg.data == this.$store.getters.pernr) {
        this.$store.dispatch("user/logout");
        // 登出系统

        location.replace(
          (localStorage.getItem("uat_login_url")
            ? localStorage.getItem("uat_login_url")
            : process.env.VUE_APP_LOGIN_URL) + "logout?type=passive"
        );
      }
    },
    send() {
      this.socket.send(params);
    },
    close() {
      console.log("socket已经关闭");
    },
  },
  destroyed() {
    // 销毁监听
    this.socket.onclose = this.close;
  },
};
</script>

 
环境变量配置,.env.dev 文件

# webscoket 链接地址
VUE_APP_WebSocket = ws://dev.api.com/

此时,websocket 通道已连接,前端收到 websocket 后执行登出操作即可

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Spring Security提供了单点登出的功能,可以通过以下步骤实现: 1. 配置LogoutFilter 在Spring Security配置文件中添加LogoutFilter,并设置logoutUrl参数为/logout,例如: ``` <security:logout logout-url="/logout" /> ``` 2. 配置SingleSignOutFilter 在Spring Security配置文件中添加SingleSignOutFilter,例如: ``` <bean id="singleSignOutFilter" class="org.jasig.cas.client.session.SingleSignOutFilter"> <property name="casServerUrlPrefix" value="https://cas.example.com" /> </bean> ``` 3. 配置SessionRegistry 在Spring Security配置文件中配置SessionRegistry,例如: ``` <bean id="sessionRegistry" class="org.springframework.security.core.session.SessionRegistryImpl" /> ``` 4. 配置ConcurrentSessionFilter 在Spring Security配置文件中添加ConcurrentSessionFilter,并设置sessionRegistry属性为上一步中配置的SessionRegistry,例如: ``` <security:concurrent-session-control session-registry-alias="sessionRegistry" /> ``` 5. 配置LogoutHandler 在Spring Security配置文件中配置LogoutHandler,例如: ``` <bean id="logoutHandler" class="org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler"> <property name="invalidateHttpSession" value="true" /> <property name="clearAuthentication" value="true" /> </bean> ``` 6. 配置LogoutSuccessHandler 在Spring Security配置文件中配置LogoutSuccessHandler,例如: ``` <bean id="logoutSuccessHandler" class="org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler"> <property name="defaultTargetUrl" value="/" /> </bean> ``` 7. 配置FilterChainProxy 在Spring Security配置文件中配置FilterChainProxy,例如: ``` <bean id="springSecurityFilterChain" class="org.springframework.security.web.FilterChainProxy"> <sec:filter-chain-map path-type="ant"> <sec:filter-chain pattern="/logout" filters="singleSignOutFilter,logoutFilter" /> <sec:filter-chain pattern="/**" filters="concurrencyFilter,securityFilterChain" /> </sec:filter-chain-map> </bean> ``` 以上步骤配置完成后,用户在访问/logout时会触发单点登出操作,即使用户在其他应用中也会被同时登出

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值