oauth 2.0 服务端可以参考文章: Spring Security oauth2.0 服务端
oauth 2.0 单点登录可以参考文章: Spring Security 单点登录
背景
单点登出
如何理解单点登出呢?
举例说明:A、B、C 三个系统已登录,若干时间后,A系统主动登出,触发 B、C系统被动登出,即一个系统登出,所有系统也登出
TOKEN
业务系统判断登出的关键参数,就是 TOKEN,而 TOKEN 一般有两种方式,一种是不可逆加密字符串,一种是JWT
这两种 TOKEN 分别如何校验其合法性呢?
- 加密字符串:调用 sso 服务的 /check/token 接口
- 根据 JWT 的 key,用算数的方式判断,详情可参考 Spring Security oauth2.0 客户端
方案1
第一种方案的单点登出方式比较简单:
- A系统主动登出,调用 sso 的登出接口 /logout
- sso 使 token 失效
- B、C 系统校验 token,即调用 sso 的 /check/token 接口
- B、C 系统得知 token 已失效,实现单点登出
然而实际项目中,一般不会使用这种方案,为什么呢?
因为业务系统的每个请求都访问一次 sso 的 /check/token 接口,会导致 sso 的 qps 非常之大,不符合实际情况,而 JWT 就是为解决这种场景应运而生的
方案2
JWT的单点登出方案就略为复杂了,因为 A系统登出后,虽然可以通知 sso 使 token 失效,但 B、C系统是基于 JWT 算数方式的来判断 token 有效性的,所以无法使 B、C系统登出
解决方案
- A 系统主动登出,请求 sso 的登出接口 /logout
- sso 发送 kafka 消息,广播通知 B、C 系统需要登出
- B、C 系统收到消息后,使 token 失效
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 失效后,未能即时通知前端,所以只有等用户操作时,才能使系统登出。要实现前端单点登出,有两种方案
- 业务系统前端轮询,调用一个可以查询 token 是否有效的接口,返回失败时实现登出
- 使用 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 后执行登出操作即可