reuseRefreshTokens(false)生效了时间到了仍然强制log out

** Background:**
Client reported a issue which the user always logout by the system during working. Checked the system logic, we believe it is because the access token only valid for 3 hours. That's why the user always logout at around 12pm local time.

Current Access Token Logic:
The token involve 2 part:
Access token
Default valid time = 30mins
When access token expire, system will use refresh token to get a new access token with same valid time.
Refresh token
Default valid time = 6 hours (Wunsche's setting = 3 hours)
When refresh token expire, system will logout the user.
Expectation:

When user is actively using the system, we should extend/ refresh the Refresh Token.
Generate a new refresh token with new valid time by every api request if the original refresh token haven't expire.

项目框架:angular + springboot
当前希望解决:只要ui上有操作,refresh token 永不过期,尝试使用 api: reuseRefreshTokens(false),

    @Override
    public void configure(final AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints
            .authenticationManager(authenticationManager)
            .tokenStore(tokenStore())
            .tokenEnhancer(tokenEnhancerChain())    
            .reuseRefreshTokens(false)
            .tokenGranter(appendTokenGranter(endpoints))
            .userDetailsService(userDetailsService)
            .exceptionTranslator(new WebResponseExceptionTranslatorImpl());

    }

debug 前端可以看见access token和refresh token 都刷新,但是一旦超过初始设定的refresh token过期时间,仍然会强制log out,log报错:

2023-04-19 19:40:46,661 [4fca686a-a242-4ade-bc4d-4ba1d93fd6c3] INFO  o.s.s.o.p.endpoint.TokenEndpoint - Handling error: InvalidTokenException, Invalid refresh token (expired): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRlVGltZUZvcm1hdENvZGUiOiJkZC9NTU0veXl5eSBISDptbTpzcyIsImlzVGJ5TWlncmF0aW9uIjpmYWxzZSwidXNlcl9uYW1lIjoiZ2F2aW5Ad3VuIiwiZGF0ZUZvcm1hdENvZGUiOiJkZC9NTU0veXl5eSIsImxvY2FsZSI6ImVuX1VTIiwid29ya2luZ19kb21haW4iOiJXVU5TQ0hFIiwiZXhwaXJlZE9uIjoxNjgxOTA0NDE1ODAyLCJjbGllbnRfaWQiOiJjYngiLCJkb21haW5faWQiOiJXVU5TQ0hFIiwiY3VycmVudFVzZXJDb21iaW5lZElkIjoiZ2F2aW5Ad3VuQFdVTlNDSEUiLCJvd25lckRvbWFpbklkIjoiV1VOU0NIRSIsInJlZnJlc2hUb2tlbklkIjoiNTgwYjAyMTktYjI1My00YWQzLWJlZTMtNzVhMDJmMzM2YWZmIiwic2NvcGUiOlsicmVhZCIsIndyaXRlIiwidHJ1c3QiXSwiYXRpIjoiOTM0ZWU5ZWItODlmYy00OTgzLWJlNDQtOTg1NjM0NGJhMTk1IiwibmV3dWlUaW1lWm9uZUNvZGUiOiJBZnJpY2EvQWxnaWVycyIsImV4cCI6MTY4MTkwNDQyNCwianRpIjoiNTgwYjAyMTktYjI1My00YWQzLWJlZTMtNzVhMDJmMzM2YWZmIiwiZW1haWwiOiJnYXZpbi50ZXN0QGNieHNvZnR3YXJlLmNvbSIsInJlcXVlc3RDbGllbnQiOm51bGwsImN1cnJlbnRVc2VyTmFtZSI6ImdhdmluIHd1biIsInRva2VuSWQiOiI5MzRlZTllYi04OWZjLTQ5ODMtYmU0NC05ODU2MzQ0YmExOTUiLCJ3b3JraW5nRm9yVXNlcklkIjpudWxsLCJ1c2VySWQiOiI5ZmE5ZTFhNTMxYTI0MDQwOWE1NTRiMGFiOWRlNjNkNCIsImF1dGhvcml0aWVzIjpbInVzZXIiXSwiaXNTaW5nbGVTb3VyY2luZ0RvbWFpbiI6dHJ1ZSwidXNlckxvZ2luSWQiOiJnYXZpbkB3dW4ifQ.6rMSFCuDLMkiUqx9MbgLtzuHVWnw_H6oh1wv80dYJ60
2023-04-19 19:40:46,662 [] INFO  c.c.c.c.CustomizeEmbeddedTomcatContainer - POST /oauth/token status=401, time_taken=27, bytes=1268, query_string=, request_uuid=4fca686a-a242-4ade-bc4d-4ba1d93fd6c3, remote_ip_address=127.0.0.1

以下是具体代码:

package com.cbxsoftware.rest.configuration;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.provider.CompositeTokenGranter;
import org.springframework.security.oauth2.provider.TokenGranter;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;

import com.cbxsoftware.rest.security.CbxAccessTokenConverter;
import com.cbxsoftware.rest.security.CbxTokenEnhancer;
import com.cbxsoftware.rest.security.WebResponseExceptionTranslatorImpl;
import com.cbxsoftware.rest.security.checker.AuthenticationChecker;
import com.cbxsoftware.rest.security.grant.CasTicketGranter;
import com.cbxsoftware.rest.service.DomainAttributeService;
import com.cbxsoftware.rest.service.SsoPgtService;

import lombok.RequiredArgsConstructor;

@Configuration
@RequiredArgsConstructor
@EnableAuthorizationServer
public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    private static final String GRANT_TYPE = "password";
    private static final String AUTHORIZATION_CODE = "authorization_code";
    private static final String REFRESH_TOKEN = "refresh_token";
    private static final String IMPLICIT = "implicit";
    private static final String SCOPE_READ = "read";
    private static final String SCOPE_WRITE = "write";
    private static final String TRUST = "trust";
    private static final String CAS = "cas";

    @Value("${cbx.client.id:cbx}")
    private String clientId;

    @Value("${cbx.client.secret:cbx@123}")
    private String clientSecret;

    @Value("${cbx.access_token.validitySeconds:300}")
    private Integer accessTokenValiditySeconds;

    @Value("${cbx.refresh_token.validitySeconds:21600}")
    private Integer refreshTokenValiditySeconds;

    @Value("${cbx.jwt.key:cbxRest@123}")
    private String jwtKey;

    private final AuthenticationManager authenticationManager;
    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;
    private final SsoPgtService ssoPgtService;
    private final DomainAttributeService domainAttributeService;
    private final List<AuthenticationChecker> authenticationCheckers;

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(accessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        final JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setAccessTokenConverter(new CbxAccessTokenConverter());
        converter.setSigningKey(jwtKey);
        return converter;
    }

    @Bean
    public TokenEnhancerChain tokenEnhancerChain() {
        //add additional information to JWT token
        final CbxTokenEnhancer cbxTokenEnhancer = new CbxTokenEnhancer(domainAttributeService);
        final List<TokenEnhancer> tokenEnhancers = Arrays.asList(cbxTokenEnhancer, accessTokenConverter());

        final TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(tokenEnhancers);
        return tokenEnhancerChain;
    }

    @Override
    public void configure(final ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
            .withClient(clientId)
            .secret(passwordEncoder.encode(clientSecret))
            .authorizedGrantTypes(GRANT_TYPE, AUTHORIZATION_CODE, REFRESH_TOKEN, IMPLICIT, CAS)
            .scopes(SCOPE_READ, SCOPE_WRITE, TRUST)
            .accessTokenValiditySeconds(accessTokenValiditySeconds)
            .refreshTokenValiditySeconds(refreshTokenValiditySeconds);
    }

    @Override
    public void configure(final AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints
            .authenticationManager(authenticationManager)
            .tokenStore(tokenStore())
            .tokenEnhancer(tokenEnhancerChain())    
            .reuseRefreshTokens(false)
            .tokenGranter(appendTokenGranter(endpoints))
            .userDetailsService(userDetailsService)
            .exceptionTranslator(new WebResponseExceptionTranslatorImpl());

    }

    private TokenGranter appendTokenGranter(final AuthorizationServerEndpointsConfigurer endpoints) {
        final List<TokenGranter> granters = Collections.singletonList(endpoints.getTokenGranter());
        final CompositeTokenGranter compositeTokenGranter = new CompositeTokenGranter(granters);
        compositeTokenGranter.addTokenGranter(retrieveCasTicketGranter(endpoints));
        return compositeTokenGranter;
    }

    private TokenGranter retrieveCasTicketGranter(final AuthorizationServerEndpointsConfigurer endpoints) {
        return new CasTicketGranter(
            endpoints.getTokenServices(),
            endpoints.getClientDetailsService(),
            endpoints.getOAuth2RequestFactory(),
            userDetailsService,
            ssoPgtService,
            authenticationCheckers
        );
    }
}


angular 前端拦截器代码:


```bash
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';

import { Observable } from 'rxjs';
import { first, switchMap } from 'rxjs/operators';

import { AuthService } from '../auth.service';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  constructor(private readonly authService: AuthService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (isRefreshTokenRequest(req)) {
      return next.handle(req);
    }

    return this.authService.userInfo$.pipe(
      first(),
      switchMap((userInfo) => {
        if (userInfo.access_token) {
          // Clone the request and set the new header in one step.
          const authReq = req.clone({
            setHeaders: {
              Authorization: `${userInfo.token_type} ${userInfo.access_token}`,
            },
          });

          // send cloned request with header to the next handler.
          return next.handle(authReq);
        } else {
          return next.handle(req);
        }
      }),
    );
  }
}

function isRefreshTokenRequest(req: HttpRequest<any>) {
  return typeof req.body === 'string' && req.body.includes('grant_type=refresh_token');
}



```

以下答案由GPT-3.5大模型与博主波罗歌共同编写:
从代码中可以看到,在AuthorizationServerEndpointsConfigurer中的configure方法中,我们看到了这个属性reuseRefreshTokens(false)的设置。这个属性决定了当我们使用refresh_token获取新的access_token时,会不会刷新refresh_token的有效期。

根据OAuth2的规范,refresh_token是有过期时间的,这个时间是可以配置的。如果refresh_token过期了,那么就需要用户重新登录获取新的access_token和refresh_token了,这也是为什么你的用户总是在工作时被系统登出的原因。

但现在你希望用户重新登录的需求没有了,因为只要用户在系统中进行操作,就应该刷新refresh_token的有效期。如果非常长时间没有用户操作,可以理解为用户已经离开了系统,这时可以让refresh_token过期。

默认情况下,reuseRefreshTokens属性是true,也就是说当refresh_token刷新access_token时,同时也会刷新refresh_token的有效期,这个也是OAuth2规范中的默认行为。如果设置为false,那么refresh_token的有效期只会延长一段时间,而不是无限期延长。

在你的代码中,你已经将reuseRefreshTokens属性设置为false了,因此你期望的行为是每次用户操作时刷新refresh_token的有效期。但是根据你的描述,它并没有生效。

那么问题出在哪里呢?我们暂且不管前端的代码,它应该没有问题。问题出在后端Spring Security Oauth2框架的配置中。

根据你提供的代码,我们看到你只对access token进行了增强,也就是重写了类CbxAccessTokenConverter和类CbxTokenEnhancer。但是它们只影响了access token,对refresh token并没有产生影响,也就是说情况还和原来一样。

所以你需要在类OAuth2AuthorizationServerConfig的configure方法中再进一步增强token。正确的代码如下:

    @Override
    public void configure(final AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints
            .authenticationManager(authenticationManager)
            .tokenStore(tokenStore())
            .tokenEnhancer(tokenEnhancerChain())
            .accessTokenConverter(accessTokenConverter())
            .reuseRefreshTokens(false)
            .tokenGranter(appendTokenGranter(endpoints))
            .userDetailsService(userDetailsService)
            .exceptionTranslator(new WebResponseExceptionTranslatorImpl())
            .addInterceptor(new TokenStoreInterceptor(tokenStore()));
    }

    private static class TokenStoreInterceptor implements HandlerInterceptor {
        private final TokenStore tokenStore;

        TokenStoreInterceptor(TokenStore tokenStore) {
            this.tokenStore = tokenStore;
        }

        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            String refreshTokenValue = request.getParameter("refresh_token");
            if (refreshTokenValue != null) {
                OAuth2AccessToken refreshToken = tokenStore.readRefreshToken(refreshTokenValue);
                if (refreshToken != null) {
                    tokenStore.removeAccessTokenUsingRefreshToken(refreshToken);
                    tokenStore.removeRefreshToken(refreshToken);
                }
            }

            return true;
        }
    }

可以注意到,我们添加了一个拦截器TokenStoreInterceptor,在每次请求时检查是否带有refresh_token,如果这时候refresh_token的有效期已经过了,那么就将之前已经使用过的access_token和refresh_token删除。

这样就可以实现你期望的功能了。
如果我的回答解决了您的问题,请采纳!

  • reuseRefreshTokens(false) 选项用于指定是否可以重复使用刷新令牌。 如果此选项设置为 false,每次使用刷新令牌时,都会生成一个新的刷新令牌,旧的刷新令牌将被丢弃。这意味着,每次使用刷新令牌时,都会生成一个新的刷新令牌并替换旧的刷新令牌,直到访问令牌过期为止。

  • 如果您希望刷新令牌不过期,您可以使用 tokenValiditySeconds 选项将访问令牌和刷新令牌的有效期设置为较长时间(例如,将其设置为 Integer.MAX_VALUE)。

  • 但是,这不是一个很好的安全实践,因为刷新令牌通常用于长时间保持用户会话活动。如果刷新令牌永远不会过期,则会增加安全风险,因为攻击者可能会使用窃取的令牌来访问用户帐户。如果您的应用程序需要这种类型的功能,则应该仔细权衡安全风险,并考虑使用其他方法来增强安全性,例如使用双因素身份验证或限制访问尝试次数等。

引用chatGPT作答,根据你提供的信息,应该是因为 refresh token 已经过期了,导致服务器认为当前的 refresh token 无效,从而强制用户注销。

在 OAuth 2.0 协议中,当 refresh token 超过其初始设定的过期时间后,就会被服务器视为无效的。这个设定通常是由服务器端的配置参数来控制的。

你提到你已经尝试在服务器端设置 reuseRefreshTokens(false),表示每次使用 refresh token 刷新 access token 时都会生成一个新的 refresh token,而不是重复使用之前的 refresh token。这个配置对于防止 refresh token 的滥用有很好的效果,但是它并不会改变 refresh token 过期的行为。

如果你希望在用户进行操作时保持 access token 的有效性,你可以在前端实现一个定时器,定期地向服务器发送一个带有 access token 的验证请求。这样可以保证用户进行操作时 access token 始终是有效的。

另外,如果你希望延长 refresh token 的过期时间,你可以修改服务器端的配置参数,将其设为更长的时间。但是需要注意,这样会增加 refresh token 被盗用的风险,因此需要在安全性和用户体验之间进行权衡。

根据您提供的代码和描述,可能是由于 Refresh Token 过期时间的设置不正确导致的。在您的配置中,access token 的过期时间设置为 300 秒,而 refresh token 的过期时间设置为 21600 秒。这意味着在用户使用您的应用程序之后的 5 分钟内,他们可以在未重新授权的情况下使用 refresh token 获得新的 access token。然而,如果 5 分钟后用户未操作应用,则会被强制注销。您可以尝试增加 refresh token 的过期时间来验证这一点。

此外,当您使用 reuseRefreshTokens(false) 时,它将禁用刷新令牌的重用。这意味着在每个刷新请求期间,授权服务器都会生成一个新的 refresh token。如果您希望用户在应用程序中继续使用 refresh token,则可以尝试将其设置为 true。 例如:

.endpoints
    ...
    .reuseRefreshTokens(true)
    ...

您的问题是关于页面刷新的,那么这个拦截器代码可能并没有直接影响。