文章目录
- 1. 延长页面自动退出登录时间
- 1. 认证登录控制器 AuthController
- 2. 认证登录业务逻辑层 LoginServiceImpl
- 3. 工具类 RedisKeyUtil
- 4. 自定义 CustomTokenExtractor 继承 BearerTokenExtractor
- 5. 资源服务器配置类 ResourceServerAutoConfiguration
- 2. 延长系统退出登录的时间-源码流程分析
- 1. 用户登录获取令牌
- 2. 通过访问令牌访问系统资源 HelloController
- 3. 源码分析
- 1. 请求进入过滤器 OAuth2AuthenticationProcessingFilter#doFilter 方法
- 2. 请求进入 BearerTokenExtractor#extract 方法
- 3. 请求进入自定义 CustomTokenExtractor#extractToken 方法
- 4. 请求进入拦截器 UserInfoInterceptor#preHandler 方法
- 5. 请求进入控制器 HelloController#hello方法
- 3. 页面自动退出登录-源码流程分析
- 1. 通过访问令牌访问系统资源 HelloController
- 2. 源码分析
- 1. 请求进入过滤器 OAuth2AuthenticationProcessingFilter#doFilter 方法
- 2. 请求进入 BearerTokenExtractor#extract 方法
- 3. 请求进入自定义 CustomTokenExtractor#extractToken 方法
在前面几篇文章中,梳理了 SpringSecurity Oauth2 的认证登录流程,我们知道认证成功后的token信息是存储在redis中,现在有一个需求:实现一个心跳机制,当用户使用账号密码登录系统后,如果10分钟内有请求访问系统,那么就延长系统退出登录的时间,如果10min内没有任何请求访问系统,那么10分钟后系统将退出登录。如何实现改需求呢?
根据前面文章的源码分析我们知道请求会先经过OAuth2AuthenticationProcessingFilter过滤器,该过滤器第一步就是提取token,如果token=null代表用户未认证,因此我们可以利用这一逻辑,实现一个自定义的 CustomTokenExtractor ,在请求进来的时候判断控制页面自动退出登录的 redis key是否存在,如果不存在说明已经到达系统自动退出登录的时间,返回给OAuth2AuthenticationProcessingFilter过滤器token=null,用户未认证信息。如果key存在就延长key的过期时间。
我们先来实现该功能,然后走读源码带大家看下整体流程。
1. 延长页面自动退出登录时间
1. 认证登录控制器 AuthController
@Slf4j
@RestController
@RequestMapping("/api/v1")
public class AuthController {
@Autowired
private LoginService loginService;
@PostMapping("/login")
public ApiResponse<AuthenticationInfo> authority( @Validated @RequestBody LoginQo loginQo) {
AuthenticationInfo authenticationInfo = loginService.checkAndAuth( loginQo);
return new ApiResponse<>(0,"success",authenticationInfo);
}
}
2. 认证登录业务逻辑层 LoginServiceImpl
- 校验登录密码是否正确;
- 获取访问令牌access_token;
- redis存储认证信息;
@Service
@Slf4j
public class LoginServiceImpl implements LoginService {
@Autowired
private UserService userService;
@Autowired
private RestTemplate restTemplate;
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 登录认证
* @param loginQo 登录请求体
* @return AuthenticationInfo
*/
@Override
public AuthenticationInfo checkAndAuth(LoginQo loginQo) {
// 登录认证获取访问令牌和用户信息
AuthenticationInfo authenticationInfo = checkLogin(loginQo);
// 处理redis中的认证数据
processRedisAuthInfo(authenticationInfo);
return authenticationInfo;
}
/**
* 处理redis中的认证数据
* @param authenticationInfo 认证成功后的令牌和用户信息
*/
private void processRedisAuthInfo(AuthenticationInfo authenticationInfo) {
AuthToken authToken = authenticationInfo.getAuthToken();
String accessToken = authToken.getAccessToken();
String refreshToken = authToken.getRefreshToken();
User userInfo = authenticationInfo.getUser();
// 用户登录后,页面自动退出登录的时间(分钟)
int overTime = 10;
// 页面自动退出登录的时间,单位毫秒
long pageOverTimeLong = (long) overTime * 1000 * 60;
// 当前时间戳,单位毫秒
long nowMilliseconds = getNowMilliseconds();
// 过期时间 = 当前时间+过期时长
String overMillTimeStr = String.valueOf(nowMilliseconds + pageOverTimeLong);
// 控制页面自动退出登录的 redis key
// hh_auth_page_over_time_with_token:accessToken----overMillTimeStr----pageOverTimeLong
String pageOverTimeWithTokenKey = RedisKeyUtil.getPageOverTimeWithTokenKey(accessToken);
redisTemplate.opsForValue().set(pageOverTimeWithTokenKey, overMillTimeStr, pageOverTimeLong, TimeUnit.MILLISECONDS);
// 全局的过期时长配置,用于延长页面自动退出登录的时间(永不过期,一旦过期就无法使用了)
// hh_auth_global_page_over_time----String.valueOf(pageOverTimeLong)
String globalPageOverTimeKey = RedisKeyUtil.getGlobalPageOverTimeKey();
redisTemplate.opsForValue().set(globalPageOverTimeKey, String.valueOf(pageOverTimeLong));
// 用户信息(包括角色和权限)存储到redis 过期时间为页面过期时长
// hh_auth_access_token:accessToken----authenticationInfo----pageOverTimeLong
String authAccessTokenKey = RedisKeyUtil.getAuthAccessTokenKey(accessToken);
redisTemplate.opsForValue().set(authAccessTokenKey, JSON.toJSONString(authenticationInfo), pageOverTimeLong, TimeUnit.MILLISECONDS);
// 用户认证信息(用于刷新token使用) 过期时间JWT过期两倍的时长
String userId = userInfo.getId();
Integer expiresIn = authToken.getExpiresIn();
int refreshExpiresIn = expiresIn * 2;
// hh_auth_user_authentication:userId----authenticationInfo----refreshExpiresIn
String refreshUserAuthenticationKey = RedisKeyUtil.getUserAuthenticationKey(userId);
redisTemplate.opsForValue().set(refreshUserAuthenticationKey, JSON.toJSONString(authenticationInfo), refreshExpiresIn, TimeUnit.SECONDS);
// hh_auth_refresh_token:refreshToken----authenticationInfo----refreshExpiresIn
String refreshTokenKey = RedisKeyUtil.getAuthRefreshTokenKey(refreshToken);
redisTemplate.opsForValue().set(refreshTokenKey, JSON.toJSONString(authenticationInfo), refreshExpiresIn, TimeUnit.SECONDS);
// 存储用户的登陆token
// hh_auth_user_login_token:userId----accessToken----expiresIn
String userLoginTokenKey = RedisKeyUtil.getUserLoginTokenKey(userId);
redisTemplate.opsForSet().add(userLoginTokenKey, accessToken);
redisTemplate.expire(userLoginTokenKey, expiresIn, TimeUnit.SECONDS);
recordLoginState(userInfo.getUsername(), expiresIn);
}
/**
* 记录登陆状态
*/
private void recordLoginState(String username, Integer pageOverTimeLong) {
// hh_auth_login_status:username----online----pageOverTimeLong
String userLoginStatusKey = RedisKeyUtil.getUserLoginStatusKey(username);
redisTemplate.opsForValue().set(userLoginStatusKey, "online", pageOverTimeLong, TimeUnit.SECONDS);
}
/**
* 当前时间戳,单位为毫秒
*/
public long getNowMilliseconds() {
return LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
/**
* 登陆认证获取访问令牌和用户信息
*
* @param loginQo 登录请求体
* @return AuthenticationInfo
*/
private AuthenticationInfo checkLogin(LoginQo loginQo) {
// 密码认证:数据库中的密码是经过bcrypt加密算法存储的
User user = userService.queryByName(loginQo.getName());
String dbEncryptPwd = user.getPassword();
boolean isPassed = BcryptUtil.bEncryptMatch(loginQo.getPassword(), dbEncryptPwd);
// 登陆失败
if (!isPassed) {
log.info("the user: {} login failed, account or password is wrong", user.getId());
throw new RuntimeException("用户账号或者密码错误");
}
// 登录成功,获取用户认证信息
AuthJwtPrincipal authJwtPrincipal = AuthJwtPrincipal.builder().userId(user.getId()).build();
AuthenticationInfo authenticationInfo = obtainAuthenticationInfo(authJwtPrincipal,user);
String accessToken = authenticationInfo.getAuthToken().getAccessToken();
if(StringUtils.isBlank(accessToken)){
throw new RuntimeException("用户账号或者密码错误");
}
return authenticationInfo;
}
/**
* 获取认证信息
* @param authJwtPrincipal 认证主体
* @param user 用户信息
* @return AuthenticationInfo
*/
private AuthenticationInfo obtainAuthenticationInfo(AuthJwtPrincipal authJwtPrincipal,User user) {
AuthenticationInfo authenticationInfo = new AuthenticationInfo();
// SpringSecurity Oauth2获取access_token
AuthToken authToken = getAccessToken(authJwtPrincipal);
authenticationInfo.setAuthToken(authToken);
authenticationInfo.setUser(user);
return authenticationInfo;
}
/**
* 通过登陆信息获取相应的令牌对象
* @param authJwtPrincipal 认证主体
* @return AuthToken
*/
public AuthToken getAccessToken(AuthJwtPrincipal authJwtPrincipal) {
String loginJsonString = JSON.toJSONString(authJwtPrincipal);
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("username", loginJsonString);
// 因为使用的是自定义认证方式 CustomerAuthProvider,密码用不到,所以设为一个空值即可
map.add("password", StringUtils.EMPTY);
map.add("client_id", "client_id");
map.add("client_secret","client_secret");
map.add("grant_type", "password");
map.add("scope", "all");
// 这里会进行Oauth2.0的请求 在CustomerAuthProvider进行认证处理
Map response = restTemplate.postForObject("http://127.0.0.1:8081/oauth/token", map, Map.class);
if (MapUtil.isEmpty(response)) {
return null;
}
// 封装返回
AuthToken authToken = new AuthToken();
authToken.setAccessToken((String) response.get("access_token"));
authToken.setExpiresIn((Integer) response.get("expires_in"));
authToken.setRefreshToken((String) response.get("refresh_token"));
authToken.setTokenType((String) response.get("token_type"));
authToken.setScope((String) response.get("scope"));
return authToken;
}
}
3. 工具类 RedisKeyUtil
/**
* Redis的Key的生成
*/
public class RedisKeyUtil {
/**
* 获取令牌的Key
*
* @param token 令牌
* @return String
*/
public static String getAuthAccessTokenKey(String token) {
return String.format(RedisKey.HhAuth.ACCESS_TOKEN, token);
}
/**
* 获取刷新令牌的Key
*
* @param refreshToken 刷新令牌
* @return String
*/
public static String getAuthRefreshTokenKey(String refreshToken) {
return String.format(RedisKey.HhAuth.REFRESH_TOKEN, refreshToken);
}
/**
* 全局的页面控制时长
*
* @return String
*/
public static String getGlobalPageOverTimeKey() {
return RedisKey.HhAuth.GLOBAL_PAGE_OVER_TIME;
}
/**
* token页面控制时长
*
* @param token 令牌
*/
public static String getPageOverTimeWithTokenKey(String token) {
return String.format(RedisKey.HhAuth.PAGE_OVER_TIME_WITH_TOKEN, token);
}
/**
* 获取用户认证信息
*
* @param userId 用户ID
* @return String
*/
public static String getUserAuthenticationKey(String userId) {
return String.format(RedisKey.HhAuth.USER_AUTHENTICATION, userId);
}
/**
* 用户的登陆会话token集合
*
* @param userId 用户ID
* @return String
*/
public static String getUserLoginTokenKey(String userId) {
return String.format(RedisKey.HhAuth.USER_LOGIN_TOKEN, userId);
}
/**
* 用户登录状态
*
* @param username 用户名
* @return String
*/
public static String getUserLoginStatusKey(String username) {
return String.format(RedisKey.HhAuth.USER_LOGIN_STATUS, username);
}
}
public interface RedisKey {
String NGSOC_AUTH_KEY_PREX = "hh_auth_";
/**
* 认证相关
*/
interface HhAuth {
/**
* token相关信息
*/
String ACCESS_TOKEN = StringUtils.join(NGSOC_AUTH_KEY_PREX, "access_token:%s");
/**
* refresh_token相关信息
*/
String REFRESH_TOKEN = StringUtils.join(NGSOC_AUTH_KEY_PREX, "refresh_token:%s");
/**
* 全局的页面控制时长
*/
String GLOBAL_PAGE_OVER_TIME = StringUtils.join(NGSOC_AUTH_KEY_PREX, "global_page_over_time");
/**
* token页面控制时长
*/
String PAGE_OVER_TIME_WITH_TOKEN = StringUtils.join(NGSOC_AUTH_KEY_PREX, "page_over_time_with_token:%s");
/**
* 用户认证信息
*/
String USER_AUTHENTICATION = StringUtils.join(NGSOC_AUTH_KEY_PREX, "user_authentication:%s");
/**
* 用户的登陆会话token集合
*/
String USER_LOGIN_TOKEN = StringUtils.join(NGSOC_AUTH_KEY_PREX, "user_login_token:%s");
/**
* 用户登录状态
*/
String USER_LOGIN_STATUS = StringUtils.join(NGSOC_AUTH_KEY_PREX, "login_status:%s");
}
}
4. 自定义 CustomTokenExtractor 继承 BearerTokenExtractor
在 server-resource 资源服务器服务中添加自定义 CustomTokenExtractor ,获取控制页面自动退出登录的redis key ,如果key还没有到期:
- 获取全局的过期时长(该key永不过期,存储了延长页面自动退出登录的过期时长),用于延长页面退出登录的过期时间;
- 延长控制页面退出登录的key的过期时间:当前时间+全局的过期时长;
- 延长用户token页面过期时间:当前时间+全局的过期时长;
- 刷新页面的登录状态;
- 返回accessToken;
如果key已经过期,则直接返回accessToken=null;
@Slf4j
public class CustomTokenExtractor extends BearerTokenExtractor {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 从请求中提取 token
*/
@Override
protected String extractToken(HttpServletRequest request) {
String token = null;
// 从cookie中获取token,确保tokenCookie.setHttpOnly(true)
Cookie[] cookies = request.getCookies();
if (Objects.nonNull(cookies)) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(OAuth2AccessToken.ACCESS_TOKEN)) {
token = cookie.getValue();
break;
}
}
}
// 从header中获取token
if (StringUtils.isEmpty(token)) {
log.debug("Token not found in cookies. Trying request header.");
token = extractHeaderToken(request);
}
// 从parameters获取token
if (StringUtils.isEmpty(token)) {
log.debug("Token not found in headers. Trying request parameters.");
token = request.getParameter(OAuth2AccessToken.ACCESS_TOKEN);
}
if (StringUtils.isEmpty(token)) {
log.debug("Token not found in headers and request parameters and cookie. Not an OAuth2 request.");
return null;
}
request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_TYPE, OAuth2AccessToken.BEARER_TYPE);
// 如果redisToken为null,则说明用户未认证
String redisToken = freshTimeAndGetRedisToken(request, token);
return redisToken;
}
private String freshTimeAndGetRedisToken(HttpServletRequest request, String accessToken) {
// hh_auth_page_over_time_with_token:accessToken-----overMillTimeStr----pageOverTimeLong
String pageOverTimeWithTokenKey = RedisKeyUtil.getPageOverTimeWithTokenKey(accessToken);
String expTimeMilliSecondsStr = redisTemplate.opsForValue().get(pageOverTimeWithTokenKey);
// 控制页面自动退出登录的key已经到期删除了,直接返回null,token=null用户未认证
if(StringUtils.isBlank(expTimeMilliSecondsStr)){
return null;
}
// 控制页面自动退出登录的key的过期时间
long expTimeMilliSeconds = Long.parseLong(expTimeMilliSecondsStr);
// 当前时间
long nowMillSecondes = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
// hh_auth_access_token:accessToken----authenticationInfo----pageOverTimeLong
String authAccessTokenKey = RedisKeyUtil.getAuthAccessTokenKey(accessToken);
String authenticationInfoStr = redisTemplate.opsForValue().get(authAccessTokenKey);
// 说明页面还没退出登录
if(expTimeMilliSeconds - nowMillSecondes > 0){
// 获取全局的过期时长,用于延长页面退出登录的过期时间
// hh_auth_global_page_over_time----String.valueOf(pageOverTimeLong)
String globalPageOverTimeKey = RedisKeyUtil.getGlobalPageOverTimeKey();
String pageOverTimeLong = redisTemplate.opsForValue().get(globalPageOverTimeKey);
long pageTimeoutMilliSeconds = Long.parseLong(pageOverTimeLong);
// 延长控制页面退出登录的key的过期时间:当前时间+10分钟
String pageMillTimeOutStr = String.valueOf(nowMillSecondes + pageTimeoutMilliSeconds);
redisTemplate.opsForValue().set(pageOverTimeWithTokenKey, pageMillTimeOutStr, pageTimeoutMilliSeconds, TimeUnit.MILLISECONDS);
// 修改用户token页面过期时长
redisTemplate.expire(authAccessTokenKey, pageTimeoutMilliSeconds, TimeUnit.MILLISECONDS);
// 刷新当前用户的登陆状态
freshUserLoginState(authenticationInfoStr,pageTimeoutMilliSeconds);
return accessToken;
}else{
redisTemplate.delete(authAccessTokenKey);
removeUserLoginState(authenticationInfoStr);
}
return null;
}
/**
* 刷新当前用户的登陆状态
*/
private void freshUserLoginState( String authenticationInfoStr,Long pageTimeout) {
AuthenticationInfo authenticationInfo = JSONUtil.toBean(authenticationInfoStr, AuthenticationInfo.class);
String userLoginStatusKey = RedisKeyUtil.getUserLoginStatusKey(authenticationInfo.getUser().getUsername());
redisTemplate.opsForValue().set(userLoginStatusKey, "online", pageTimeout, TimeUnit.MILLISECONDS);
}
/**
* 清除用户的登陆状态
*/
private void removeUserLoginState( String authenticationInfoStr) {
AuthenticationInfo authenticationInfo = JSONUtil.toBean(authenticationInfoStr, AuthenticationInfo.class);
String userLoginStatusKey = RedisKeyUtil.getUserLoginStatusKey(authenticationInfo.getUser().getUsername());
redisTemplate.delete(userLoginStatusKey);
}
}
5. 资源服务器配置类 ResourceServerAutoConfiguration
@Slf4j
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerAutoConfiguration extends ResourceServerConfigurerAdapter {
@Autowired
private TokenStore tokenStore;
@Value("${spring.application.name}")
private String appName;
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.resourceId(appName);
resources.tokenStore(tokenStore);
// 配置自定义的TokenExtractor
resources.tokenExtractor(tokenExtractor());
}
@Bean
@Primary
public TokenExtractor tokenExtractor() {
CustomTokenExtractor customTokenExtractor = new CustomTokenExtractor();
return customTokenExtractor;
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 放行的请求
.antMatchers("/api/v1/login").permitAll()
// 其他请求必须认证才能访问
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}
2. 延长系统退出登录的时间-源码流程分析
1. 用户登录获取令牌
登录成功后redis中存储的key,其中TOKEN开头的key为SpringSecurity Oauth2相关的key:
可以看到控制系统页面自动退出登录的时间的key的过期时长一致在减少,当过期时长为0时,key被删除,根据前面文章的源码分析我们知道请求会先经过OAuth2AuthenticationProcessingFilter过滤器,该过滤器第一步就是提取token,如果token=null代表用户未认证,因此我们可以利用这一逻辑,实现一个自定义的 CustomTokenExtractor ,在请求进来的时候判断控制页面自动退出登录的 redis key是否存在,如果不存在说明已经到达系统自动退出登录的时间,返回给OAuth2AuthenticationProcessingFilter过滤器token=null,用户未认证信息。如果key存在就延长key的过期时间。下面我们来看源码分析下整个过程。
2. 通过访问令牌访问系统资源 HelloController
@RestController
@RequestMapping("/api/v1")
public class HelloController {
@GetMapping("/hello")
public String hello(HttpServletRequest request){
String username = UserInfoShareHolder.getUserInfo().getUsername();
return username;
}
}
3. 源码分析
1. 请求进入过滤器 OAuth2AuthenticationProcessingFilter#doFilter 方法
请求首先被OAuth2AuthenticationProcessingFilter过滤器拦截,在该过滤器的doFilter方法中主要做了以下事情:
- 从请求中提取 token 并获取待认证的Authentication 对象: Authentication authentication = tokenExtractor.extract(request);
- request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, authentication.getPrincipal());
- 通过待认证的Authentication对象倒TokenStore中获取完成的Authentication对象:Authentication authResult = authenticationManager.authenticate(authentication);
- 发布认证成功的事件通知:eventPublisher.publishAuthenticationSuccess(authResult);
- SecurityContextHolder.getContext().setAuthentication(authResult);
- 进入过滤器链中的下一个过滤器;
当用户登录后,如果有请求访问系统将会延长系统退出登录的时间:我们要做的就是实现自定义CustomTokenExtractor继承自BearerTokenExtractor,然后在提取token时判断控制页面自动退出登录的key是否存在,如果key存在就延长key的过期时长。
2. 请求进入 BearerTokenExtractor#extract 方法
3. 请求进入自定义 CustomTokenExtractor#extractToken 方法
4. 请求进入拦截器 UserInfoInterceptor#preHandler 方法
5. 请求进入控制器 HelloController#hello方法
至此,整个流程就分析完了,核心点就在CustomTokenExtractor类的实现上。
3. 页面自动退出登录-源码流程分析
我们要做的就是实现自定义CustomTokenExtractor继承自BearerTokenExtractor,然后在提取token时判断控制页面自动退出登录的key是否存在,如果key不存在说明已经达到页面自动退出登录的时长,那么accessToken=null,OAuth2AuthenticationProcessingFilter#doFilter 方法在判断accessToken=null后就会进入下一个过滤器,最终响应用户未认证的信息,页面自动退出登录。
redis中控制页面自动登录的key已经过期,(hh_auth_page_over_time_with_token:accessToken)此时如果访问系统资源将响应用户未认证的异常消息。
1. 通过访问令牌访问系统资源 HelloController
@RestController
@RequestMapping("/api/v1")
public class HelloController {
@GetMapping("/hello")
public String hello(HttpServletRequest request){
String username = UserInfoShareHolder.getUserInfo().getUsername();
return username;
}
}
判断redis中控制页面自动登录的key是否过期,如果过期删除则返回accessToken=null,代表用户未认证,需要重新登录系统。
2. 源码分析
1. 请求进入过滤器 OAuth2AuthenticationProcessingFilter#doFilter 方法
2. 请求进入 BearerTokenExtractor#extract 方法
3. 请求进入自定义 CustomTokenExtractor#extractToken 方法