spring-security官网对Authentication Persistence and Session Management相关的介绍中提到有关Customizing Where the Authentication Is Stored(自定义认证存储位置)的相关内容,内容中提到可以把已验证的身份信息存储在缓存或数据库中以便进行水平扩展。针对此内容我们来实现一个基于缓存的认证存储。
没有基于数据库存储是因为假设针对用户名/密码认证的时候从用户表加载用户信息,然后验证用户信息,验证通过,再把已验证的身份信息存储在数据库中,存储的内容和从用户表加载的内容基本一致,然后每次再查询数据库判断用户是否已经验证,很麻烦,对数据库查询也频繁。针对数据库的存储分析可能是片面的,大家可以根据实际需求来。
针对缓存认证信息,我们采用H2+Redis的实现方式。用H2存储用户信息,用Redis缓存已经验证的身份信息。
处理过程:通过访问“/login”进行登录,登录成功后,缓存已验证的身份信息,然后转发到“/home”请求,在跳转期间从缓存中获取已验证的身份信息,最终成功访问“/home”端点。
首先创建一个基于spring security的spring boot应用程序,配置前面提到的有关H2和Redis相关的内容。
pom.xml中依赖的dependencies,spring-boot版本用的3.2.2。
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-oauth2-authorization-serverartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-jdbcartifactId>
dependency>
<dependency>
<groupId>com.h2databasegroupId>
<artifactId>h2artifactId>
<version>2.2.224version>
dependency>
<dependency>
<groupId>com.alibaba.fastjson2groupId>
<artifactId>fastjson2artifactId>
<version>2.0.46version>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>io.lettucegroupId>
<artifactId>lettuce-coreartifactId>
<version>6.3.1.RELEASEversion>
dependency>
dependencies>
在defaultSecurityFilterChain方法中设置了所有请求都需要进行认证。formLogin中指定了登录成功后转发的“/home”的处理。securityContext中指定了自定义缓存Authentication的相关处理。session的创建策略是NEVER。requestCache设置为不对需要身份认证的请求进行保存。
在往下配置中包含了一个默认初始化的嵌入式DataSource(H2),并存储了两个用户“user”和“admin”密码都是“pasword”。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Resource
HashMapping mapping;
@Bean
@Order(1)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.formLogin((formLoginCustomizer) -> formLoginCustomizer
.successHandler(new LoginRequestAwareAuthenticationSuccessHandler())
)
.securityContext((context) ->
context.securityContextRepository(new CacheSecurityContextRepository(mapping)))
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.NEVER))
.requestCache((request) -> request.requestCache(new NullRequestCache()));
return http.build();
}
/**
* @return 嵌入式数据源
*/
@Bean
DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.setType(H2)
.addScript(JdbcDaoImpl.DEFAULT_USER_SCHEMA_DDL_LOCATION)
.build();
}
/**
* @return 数据库管理用户,密码:password
*/
@Bean
UserDetailsManager users(DataSource dataSource) {
UserDetails user = User.builder()
.username("user")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER")
.build();
UserDetails admin = User.builder()
.username("admin")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER", "ADMIN")
.build();
JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
users.createUser(user);
users.createUser(admin);
return users;
}
@Bean
public JWKSource jwkSource() {
JWKSet jwkSet = new JWKSet(JwtTokenUtil.getKey());
return new ImmutableJWKSet<>(jwkSet);
}
}
这里使用的连接模式是Redis Sentinel 因为在spring-data-redis【3.2.2 版本】中不在支持Standalone和Master/Replica连接模式。如果你本地使用的开发环境是Windows需要你自行配置redis的环境,你可以下载Redis Windows安装程序,然后搭建环境。可参考链接。
@Configuration
public class RedisConfig {
/**
* Lettuce
*/
@Bean
public RedisConnectionFactory lettuceConnectionFactory() {
RedisSentinelConfiguration sentinelConfig = new RedisSentinelConfiguration()
.master("mymaster")
.sentinel("127.0.0.1", 26379)
.sentinel("127.0.0.1", 26380)
.sentinel("127.0.0.1", 26381);
return new LettuceConnectionFactory(sentinelConfig);
}
@Bean
RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
return template;
}
}
@Configuration
public class HashMapping {
@Resource(name = "redisTemplate")
HashOperations<Object, String, Object> hashOperations;
ObjectMapper objectMapper = new ObjectMapper()
// 注册自定义模式
.registerModule(new ParameterNamesModule(JsonCreator.Mode.PROPERTIES));
Jackson2HashMapper mapper = new Jackson2HashMapper(objectMapper, false);
public void writeHash(String key, UsernamePasswordAuthenticationToken person) {
Map<String, Object> mappedHash = mapper.toHash(person);
hashOperations.putAll(key, mappedHash);
}
public UsernamePasswordAuthenticationToken loadHash(String key) {
Map<String, Object> loadedHash = hashOperations.entries(key);
// UsernamePasswordAuthenticationToken没有默认构造函数转换失败
// mapper.fromHash(loadedHash);
UsernamePasswordAuthenticationTokenMixin authenticationMixin = new UsernamePasswordAuthenticationTokenMixin(loadedHash);
return authenticationMixin.getAuthentication();
}
public boolean exist(String key) {
return hashOperations.size(key) > 0;
}
}
spring security的逻辑是判断一个请求如果需要进行身份验证,它需要通过SecurityContextRepository#loadContext方法看是否能获取到已验证的身份信息,如果获取到则直接访问对应的请求,获取不到则说明用户没有进行身份认证,则需要跳转到“/login”进行登录,登录成功,会把已验证的身份信息存储起来,调用SecurityContextRepository#saveContext方法。我们这里saveContext方法的逻辑是首先生成一个token,然后把token进行MD5获取一个32位的字符串作为key,Authentication作为value存储在redis中,saveContext方法执行完,就需要执行登录成功后的逻辑,上面我们在security配置了formLogin对应的successHandler处理,里面的逻辑是再次转发到“/home”请求中,因为“/home”请求会重复上面的逻辑,调用loadContext方法,这时会通过链接携带的token获取到已验证的身份信息,说明用户已经验证过,可安全访问“/home”端点,但这里会提示“404”,因为程序中没有对应“/home”端点,这里只是为了验证能成功跳转到“/home”,而不是再次跳转到登录页面,这样就基本达到目的了。通过缓存加载认证信息,而不是通过官方提供的默认RequestAttributeSecurityContextRepository和HttpSessionSecurityContextRepository获取认证信息,便于水平扩展。
public class CacheSecurityContextRepository implements SecurityContextRepository {
private static final Log log = LogFactory.getLog(CacheSecurityContextRepository.class);
public static final String ACCESS_TOKEN = "access_token";
private BearerTokenResolver bearerTokenResolver = new DefaultBearerTokenResolver();
HashMapping hashMapping;
public CacheSecurityContextRepository(HashMapping hashMapping) {
this.hashMapping = hashMapping;
}
@Override
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
log.info("进入loadContext方法");
HttpServletRequest request = requestResponseHolder.getRequest();
String token = getToken(request);
if (null == token) {
return null;
}
if (!StringUtils.hasText(token)) {
log.warn("请求中token参数为空!");
return null;
}
log.info("获取token:" + token);
String key = DigestUtils.md5DigestAsHex(token.getBytes());
log.info("生成缓存key:" + key);
UsernamePasswordAuthenticationToken authenticationToken = hashMapping.loadHash(key);
log.info("根据key:" + key + ",获取缓存数据:" + JSON.toJSONString(authenticationToken));
return new SecurityContextImpl(authenticationToken);
}
@Override
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
log.info("进入saveContext方法");
if (context instanceof SecurityContextImpl securityContext) {
Authentication authentication = securityContext.getAuthentication();
if (!authentication.isAuthenticated()) {
log.warn("用户认证未认证成功!");
return;
}
log.info("用户已认证信息:" + JSON.toJSONString(authentication));
if (authentication.getPrincipal() instanceof User user) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = (UsernamePasswordAuthenticationToken) authentication;
String token;
try {
token = JwtTokenUtil.generateToken(user);
} catch (JOSEException e) {
log.error("生成token异常", e);
return;
}
log.info("获取token:" + token);
String key = DigestUtils.md5DigestAsHex(token.getBytes());
log.info("生成缓存key:" + key);
hashMapping.writeHash(key, usernamePasswordAuthenticationToken);
log.info("根据key:" + key + "缓存数据完成!");
request.setAttribute(ACCESS_TOKEN, token);
}
}
}
@Override
public boolean containsContext(HttpServletRequest request) {
log.info("进入containsContext方法");
String token = getToken(request);
if (null == token) {
return false;
}
String key = DigestUtils.md5DigestAsHex(token.getBytes());
boolean exist = hashMapping.exist(key);
log.info("key:" + key + ",存在:" + exist);
return exist;
}
private String getToken(HttpServletRequest request) {
String token = request.getParameter(ACCESS_TOKEN);
if (!StringUtils.hasText(token)) {
token = null != request.getAttribute(ACCESS_TOKEN) ? request.getAttribute(ACCESS_TOKEN).toString() : null;
}
if (!StringUtils.hasText(token)) {
token = bearerTokenResolver.resolve(request);
}
return token;
}
}
public class LoginRequestAwareAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private static final Log log = LogFactory.getLog(LoginRequestAwareAuthenticationSuccessHandler.class);
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws ServletException, IOException {
Object tokenObject = request.getAttribute(CacheSecurityContextRepository.ACCESS_TOKEN);
String token = "";
if (null != tokenObject) {
token = tokenObject.toString();
}
String targetUrl = "/home?" + CacheSecurityContextRepository.ACCESS_TOKEN + "=" + token;
this.redirectStrategy.sendRedirect(request, response, targetUrl);
}
}
我把程序的源码托管到GitHub上了,可去查看,这里就不把所有涉及的类全部贴出来了。
参考官网[spring-security 6.2.1 ]
参考Authentication Persistence and Session Management相关翻译