问题
之前实现使用过一次Spring Session集中会话管理:《Spring Session和拦截器集成做简单Restful接口登录超时验证》
现在需要在这个集中会话管理的基础上面,加上SSO单点登录即可。
思路
会话拦截器,仍旧负责会话的登录状态检查。只是这次在登录的时候,需要检查当前用户的所有会话,然后,把其他会话统统删除,只保留当前登录成功的有效会话。这样就实现了SSO。有效会话的记录,仍旧保留在redis中。
步骤
Maven依赖
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
Spring配置
application.yml
spring:
session:
redis:
flush-mode: on_save
namespace: xxx:session
timeout: P30D
data:
redis:
host: 127.0.0.1
port: 6379
password: xxxxx
database: 0
server:
port: 8080
servlet:
session:
cookie:
same-site: strict
secure: true
http-only: true
这里主要是配置怎么连redis,以及会话过期时间为30天。为了cookie的安全,将same-site,secure和http-only设置一遍。
会话认证拦截器实现
SessionTimeOutInterceptor.java
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xxx.c2.comm.Result;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.PrintWriter;
@Component
public class SessionTimeOutInterceptor implements HandlerInterceptor {
public static final String USER_AUTH_KEY = "user";
@Resource
private ObjectMapper mapper;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession();
Object userId = session.getAttribute(USER_AUTH_KEY);
if (userId != null){
return HandlerInterceptor.super.preHandle(request, response, handler);
} else {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
PrintWriter out = response.getWriter();
out.print(mapper.writeValueAsString(Result.builder().code("200").message("请先登录").build()));
out.flush();
return false;
}
}
}
这里只要检查到session中存在user
的属性,就表示这个session是登录成功的。
登录实现
UserController.java
import com.xxx.c2.services.UserService;
import com.xxx.c2.vo.UserReq;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
@Resource
private UserService userService;
@GetMapping("/index")
public String index() {
return "Greetings from Spring Boot!";
}
@PostMapping("/login")
public ResponseEntity<?> login(HttpSession session, @RequestBody UserReq userReq) {
return userService.login(session, userReq);
}
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpSession session) {
session.invalidate();
return ResponseEntity.ok().build();
}
}
这里主要体现了退出时,使其当前会话失效。具体登录处理都在userService.login(session, userReq);
中,接下来,看看具体是如何实现。
定义如下UserService接口:
UserService.java
public interface UserService {
void ssoLogin(HttpSession session, User user);
/**
* 登录
*/
ResponseEntity<?> login(HttpSession session, UserReq userReq);
}
接口实现如下:
UserServiceImp.java
import com.xxx.c2.comm.Result;
import com.xxx.c2.interceptor.SessionTimeOutInterceptor;
import com.xxx.c2.model.User;
import com.xxx.c2.services.UserService;
import com.xxx.c2.vo.UserReq;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpSession;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.session.SessionRepository;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
@Service
public class UserServiceImp implements UserService {
@Resource
private RedisTemplate redisTemplate;
@Resource
private SessionRepository sessionRepository;
@Override
public void ssoLogin(HttpSession session, User user) {
String key = "user";
boolean add = true;
String currentSessionId = session.getId();
String userId = user.getId().toString();
HashOperations ops = redisTemplate.opsForHash();
List<String> sessionIdList = new ArrayList<>();
if (ops.hasKey(key, userId)){
sessionIdList = (List<String>) ops.get(key, userId);
if (!CollectionUtils.isEmpty(sessionIdList)) {
Iterator<String> iterable = sessionIdList.iterator();
while (iterable.hasNext()) {
String sessionId = iterable.next();
if (currentSessionId.equals(sessionId)){
add = false;
} else {
sessionRepository.deleteById(sessionId);
iterable.remove();
}
}
ops.put(key, userId, sessionIdList);
}
}
if (add) {
sessionIdList.add(currentSessionId);
ops.put(key, userId, sessionIdList);
}
}
@Override
public ResponseEntity<?> login(HttpSession session, UserReq userReq) {
// TODO 判断是否登录成功
this.ssoLogin(session, User.builder().id(Long.parseLong("1")).build());
session.setAttribute(SessionTimeOutInterceptor.USER_AUTH_KEY, "x");
return ResponseEntity.ok(Result.builder().message("登录成功").build());
}
}
这里主要就是sso关键实现逻辑代码,根据用户id查询是否存在当前用户的有效会话,如果不存在有效会话,就记录当前会话;如果存在,就先踢掉老会话,记录新会话,即可。接下来,还需要配置一下redis和配置拦截器。
redis配置
RedisConfig.java
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.WRAPPER_ARRAY);
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(objectMapper, Object.class);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// key采用String的序列化方式
template.setKeySerializer(stringRedisSerializer);
// hash的key也采用String的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
// value序列化方式采用jackson
template.setValueSerializer(jackson2JsonRedisSerializer);
// hash的value序列化方式采用jackson
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
这里不要使用Spring默认的ObjectMapper实例,因为这里会对ObjectMapper进行个性化修改。
拦截器配置
WebMvcConfig.java
import com.xxx.SessionTimeOutInterceptor;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Resource
private SessionTimeOutInterceptor sessionTimeOutInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
WebMvcConfigurer.super.addInterceptors(registry);
registry.addInterceptor(sessionTimeOutInterceptor).addPathPatterns("/**").excludePathPatterns("/login");
}
}
这里主要配置会话认证拦截器对所有请求生效,除了登录接口。
总结
到这里Spring Session的SSO简单实现就这样了。这个部分还缺少用户认证的实现,也没有使用DB来记录历史会话,使用的redis进行简单记录。