背景与目标

在 ModelEngine 三合一部署架构中,非北向接口的鉴权由 OMS 负责:

  • 前端请求经 OMS Nginx 转发至 OMS Gateway,在此完成用户身份校验,后续请求再流转至相关服务。
  • app-platform 在现有非北向接口调用链中不参与鉴权,也无需感知鉴权逻辑。

现有需求需要单独部署 app-platform,因此在缺乏 OMS 鉴权的场景下,app-platform 的非北向接口需要新增用户鉴权机制。

由于 model engine 官网(后续称为 website)已有完整的用户鉴权和用户管理体系,本次方案目标是让 app-platform 的非北向接口复用 website 的鉴权,实现统一用户体系和单点登录体验。

功能目标:

  • 在无 OMS 环境下保证 app-platform 非北向接口具备安全鉴权。
  • 复用 website 现有用户体系,实现单点登录(SSO)。
  • 保证用户体验:不同登录状态下自动跳转、无需重复授权。
  • 保证安全性:认证过程安全可靠,支持 token 校验、过期处理。
  • 客户端在跳转登陆完成后应当回到跳转前页面。

增强目标:

  • 本次鉴权功能新增为 可选功能,默认不开启,不影响原有 OMS 鉴权逻辑。
  • 鉴权功能可以通过配置或者插件开关,便于未来单独部署 app-platform 时灵活启用或禁用。

需求说明

用户场景与期望行为

用户当前状态 期望行为 备注
未登录 app-platform未登录 website 访问 app-platform 时,跳转 website 登录 → 登录完成后自动跳回 app-platform 并完成验证 第一次登录流程
未登录 app-platform已登录 website 访问 app-platform 时,自动跳转 website 完成免密登录 → 自动跳回 app-platform 并进入登录状态 单点登录
已登录 app-platform 访问 app-platform 时直接通过验证,无需再次授权 保持会话

方案选择

方案一:独立会话管理(单Token有效期长)

描述:website 只提供授权,app-platform 自行管理有效期。

  • 特点:
    • 用户体验佳 → app-platform 可提供登录/登出按钮,用户在平台内操作顺畅。
    • 从 website 跳转到 app-platform 时,可自动进行登录,无需再次操作。
  • 缺点:一致性较弱 → token 与本地 session 可能不完全同步,登出或过期时存在滞后。

方案二:集中式会话管理

描述:website 不仅颁发 token,还管理会话有效期,app-platform 每次请求都校验 token 或调用 introspect。

  • 特点:一致性强 → token 或 session 过期即失效。
  • 缺点:用户体验略逊 → 每次请求可能涉及远程校验,增加延迟。

方案三:短期 token + 本地 session 缓存

描述:website 颁发短期 token(如 5~10 分钟),app-platform filter 本地生成 session 并缓存 token。

  • 特点:用户体验佳 → 大部分请求可直接使用本地 session。
  • 缺点:一致性折中 → token 与 session 可能短时间不一致。
  • 可选优化:token 快过期时 filter 自动刷新 token。

方案四:双 token(access token + refresh token)

描述:website 颁发短期 access token + 长期 refresh token,app-platform 使用 access token 调用接口,过期后自动用 refresh token 刷新。

  • 特点:用户体验佳,一致性较高。
  • 缺点:实现复杂,需要安全存储 refresh token 并处理刷新逻辑。

总体设计

短期落地(快速上线):双 token

  • 使用 OAuth2 标准 flow,access token 5–15 分钟有效,refresh token 7–30 天。
  • app-platform 本地存储 refresh token(仅服务器端,HttpOnly),自动刷新。
  • 兼顾用户体验和安全一致性,实现复杂度适中。

长期演进(多系统、跨域 SSO 统一化):跨域 SSO / OIDC

  • 搭建统一身份提供商(如 Keycloak、Auth0、Spring Authorization Server)。
  • 使用 OIDC session cookie + access token,实现真正的单点登录与单点登出。
  • 部署难度相对高,但这是业界主流。

技术选型

  • 自建认证中心:Keycloak 或者 Spring Authorization Server
  • Java 应用客户端:Nimbus OAuth 2.0 SDK + OpenID Connect SDK

Spring Authorization Server 支持 OAuth2 / OpenID Connect 标准流程(服务器端):

  • Authorization Code Flow(授权码模式)
  • Client Credentials Flow(客户端凭证模式)
  • Refresh Token Flow(刷新 token 模式)

Nimbus OAuth 2.0 SDK + OpenID Connect SDK 支持标准流程(客户端构建请求、解析响应、验证 JWT 的工具):

  • Authorization Code Flow(授权码模式)
  • Client Credentials Flow(客户端凭证模式)
  • Refresh Token Flow(刷新 token 模式)

详细内容(双token)

后端

授权端主要实现内容:

  • website 需要实现 OAuth2 授权端,允许app-platform申请access-token和 refresh-token 资源
  • website 使用非对称加密给 app-platform 签发access-token和 refresh-token
  • website 需要实现提供给 app-platform 进行refresh token的接口

客户端主要实现内容:

  • app-platform 需要实现 OAuth2 客户端,进行完整的资源申请
  • app-platform 需要自行存储和对应 refresh-token 的使用(access-token过期后自动申请)
  • app-platform 需要能使用 website 的公钥进行对 access-token 的自行解析

需要自行实现内容:

website 业务逻辑:

  • 生成密钥对:私钥签发 JWT,公钥给资源服务器验证。
  • 用户认证:用现有 Spring Security Filter 验证用户登录。
  • 颁发 token:登录成功后发 access token(JWT)+ refresh token,access token 用私钥签名。
  • 资源接口校验:访问接口时,用公钥验证 access token。

客户端逻辑(Filter):

  • token endpoint 调用(获取 access-token / refresh-token)
  • 存储 refresh-token
  • 过期后自动刷新 access-token
  • 用公钥解析 JWT claims 并映射到你的业务上下文

前端

  • website 实现 授权url 连接按钮事件完成跳转的登录页面
  • app-platform 实现登入,登出按钮

双Token方案流程图

sequenceDiagram
    participant User as 用户
    participant Client as app-platform
    participant AuthServer as website

    User->>Client: 访问客户端应用
    Client->>AuthServer: 重定向用户到授权页面\\response_type=code&client_id=xxx&redirect_uri=yyy
    AuthServer-->>User: 显示登录页面
    User->>AuthServer: 提交用户名和密码
    AuthServer-->>User: 登录成功页面(包含授权码code)
    User->>Client: 浏览器重定向返回 code

    Client->>AuthServer: POST /token grant_type=authorization_code code=xxx redirect_uri=yyy
    AuthServer-->>Client: 返回 access_token + refresh_token
		Client->>Client: 用公钥解析 access_token 获取 username 直到过期
		
    Client->>AuthServer: POST /token grant_type=refresh_token refresh_token=xxxx
    AuthServer-->>Client: 返回新的 access_token(可选新的 refresh_token)
    Client->>Client: 用公钥解析 access_token 获取 username

app-platfrom 后端

Filter行为:

无token时,不操作,给予登录按钮(从官网跳转时重定向到授权URL)

在读到过期token时,重定向至授权URL

读到有效token时,解读username置入上下文

/callback 接口:

接受param参数,

借助SDK完成请求操作得到access-token

set-cookies(access-token)

/login 接口

直接重定向至授权URL

/logout 接口

清空携带的access-token

编码阶段

授权端

服务器端主要以Spring Authorization Server实现 他只需要进行对应的导入和相应的配置文件就可以自动启动OAuth的接口例如

/**
 * 该配置类用于管理 OAuth 服务的授权服务器
 *
 * @author Maiicy
 * @since 2025-09-19
 */
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class AuthorizationServerConfig {
    private final JwtCoreUtil jwtCoreUtil;
    private final LocalTokenBlacklistService tokenBlacklistService;
    private final AuthorizationServerProperties properties; // 注入配置类
    private final AuthorizationJwtConfig jwtConfig;
    private final AuthService authService;

    /**
     * 核心:应用 SAS 默认的授权服务器安全策略
     */
    @Bean
    @Order(1)
    public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        http.formLogin(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable);

        JwtAuthFilter jwtAuthFilter = new JwtAuthFilter(jwtCoreUtil, authService, tokenBlacklistService);
        // 只有在 AbstractPreAuthenticatedProcessingFilter 之前才能保证在 EndPointFilter 之前
        // 参考:<https://stackoverflow.com/questions/46801064/add-filter-before-oauth2authenticationprocessingfilter>
        http.addFilterBefore(jwtAuthFilter, AbstractPreAuthenticatedProcessingFilter.class);
        return http.build();
    }

    /**
     * 注册客户端
     */
    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        List<RegisteredClient> clients = properties.getClients()
                .stream()
                .map(clientProps -> RegisteredClient.withId(UUID.randomUUID().toString())
                        .clientId(clientProps.getId())
                        .clientSecret(clientProps.getSecret())
                        .redirectUri(clientProps.getRedirectUri())
                        .scopes(scopes -> scopes.addAll(clientProps.getScopes()))
                        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                        .tokenSettings(TokenSettings.builder()
                                .accessTokenTimeToLive(Duration.ofSeconds(properties.getToken().getAccessTokenTtl()))
                                .refreshTokenTimeToLive(Duration.ofSeconds(properties.getToken().getRefreshTokenTtl()))
                                .build())
                        .build())
                .toList();

        return new InMemoryRegisteredClientRepository(clients);
    }

    /**
     * 生成 JWK(RSA 公私钥)供 SAS 签发 JWT 使用
     */
    @Bean
    public JWKSource<SecurityContext> jwkSource() throws Exception {
        KeyPair keyPair = jwtConfig.jwtKeyPair();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

        RSAKey rsaKey =
                new RSAKey.Builder(publicKey).privateKey(privateKey).keyID(UUID.randomUUID().toString()).build();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return new ImmutableJWKSet<>(jwkSet);
    }

    /**
     * 授权服务器元数据配置
     */
    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder().issuer(properties.getIssuer()).build();
    }
}

客户端

客户端需要编码Filter以及Handler

Filter(主要负责登陆校验)

/**
 * 全局 HTTP 过滤器,用于验证请求中的 JWT,并根据结果执行后续请求或返回 401。
 * 支持 JWK 缓存、自动刷新、验证失败回退及旧密钥清理。
 *
 * @author Maiicy
 * @since 2025-09-22
 */
@Component
public class OAuthJwtFilter implements HttpServerFilter {
    private static final Logger log = Logger.get(OAuthJwtFilter.class);

    private static final String TOKEN_KEY = "access-token";
    private static final String FILTER_ENABLE_VALUE = "enable";

    private static final String JWKS_PATH = "/oauth2/jwks";
    private final static String REDIRECT_PATH = "/v1/api/auth/redirect?redirect_uri=";

    private static final long JWKS_CACHE_MS = 24 * 60 * 60 * 1000L; // 24小时刷新
    private static final long JWKS_KEY_MAX_AGE = 2L * 24 * 60 * 60 * 1000; // 2天旧密钥保留

    private final URL jwksURL;
    private final String loginBridgeUrlPrefix;
    private final boolean enableFilter;

    // 缓存 JWK 集合
    private final CopyOnWriteArrayList<CachedJwk> cachedJwks = new CopyOnWriteArrayList<>();
    private volatile long lastJwkLoadTime = 0;

    private record CachedJwk(JWK jwk, long loadTime) {}

    OAuthJwtFilter(@Value("${oauth.oauth-endpoint}") String oauthEndpoint,
            @Value("${oauth.client-api-endpoint}") String apiEndpoint,
            @Value("${filter.oauth2-filter}") String oauthFilterEnabled){
        this.enableFilter = StringUtils.equals(oauthFilterEnabled, FILTER_ENABLE_VALUE);
        this.loginBridgeUrlPrefix = apiEndpoint + REDIRECT_PATH;
        try {
            this.jwksURL = new URL(oauthEndpoint + JWKS_PATH);
        } catch (MalformedURLException e) {
            throw new RuntimeException(e);
        }

    }

    @Override
    public String name() {
        return "OAuthJwtFilter";
    }

    @Override
    public int priority() {
        return Order.HIGH;
    }

    @Override
    public List<String> matchPatterns() {
        return Collections.singletonList("/**");
    }

    @Override
    public List<String> mismatchPatterns() {
        return Arrays.asList("/api/app/v1/**",
                "/fit/check/**",
                "/v1/api/auth/callback",
                "/v1/api/auth/login",
                "/v1/api/auth/redirect",
                "/v1/api/auth/refresh-token");
    }

    @Override
    public void doFilter(HttpClassicServerRequest request, HttpClassicServerResponse response,
            HttpServerFilterChain chain) {
        if (!this.enableFilter){
            chain.doFilter(request, response);
            return;
        }

        String accessToken = request.cookies()
                .all()
                .stream()
                .filter(cookie -> TOKEN_KEY.equals(cookie.name()))
                .findFirst()
                .map(Cookie::value)
                .orElse(null);

        if (accessToken == null) {
            sendUnAuthResponse(response);
            return;
        }

        String username = parseTokenSubject(accessToken);
        if (username == null) {
            sendUnAuthResponse(response);
            return;
        }

        UserContext operationContext = new UserContext(username,
                HttpRequestUtils.getUserIp(request),
                HttpRequestUtils.getAcceptLanguages(request));
        UserContextHolder.apply(operationContext, () -> chain.doFilter(request, response));
    }

    @Override
    public Scope scope() {
        return Scope.GLOBAL;
    }

    /**
     * 解析 JWT 并验证签名及过期时间。
     *
     * @param jwtString 待验证的 JWT 字符串
     * @return 验证成功返回 token 的 subject(用户名),否则返回 null
     */
    String parseTokenSubject(String jwtString) {
        try {
            SignedJWT signedJWT = SignedJWT.parse(jwtString);
            Date exp = signedJWT.getJWTClaimsSet().getExpirationTime();
            if (exp == null || exp.before(new Date())) {
                return null;
            }

            String kid = signedJWT.getHeader().getKeyID();

            // 定期刷新 JWK
            if (System.currentTimeMillis() - lastJwkLoadTime > JWKS_CACHE_MS) {
                refreshJwkSet();
            }

            // 尝试验证
            if (verifyWithCachedJwks(signedJWT, kid)) {
                return signedJWT.getJWTClaimsSet().getSubject();
            }

            // 验证失败,回退刷新一次
            refreshJwkSet();
            if (verifyWithCachedJwks(signedJWT, kid)) {
                return signedJWT.getJWTClaimsSet().getSubject();
            }

        } catch (ParseException | JOSEException e) {
            log.warn("JWT parse or verify failed: {}", e.getMessage());
        }
        return null;
    }

    /**
     * 用本地缓存的 JWK 验证 JWT。
     */
    boolean verifyWithCachedJwks(SignedJWT signedJWT, String kid) throws JOSEException {
        long now = System.currentTimeMillis();
        List<CachedJwk> activeKeys = cachedJwks.stream().filter(c -> now - c.loadTime <= JWKS_KEY_MAX_AGE).toList();

        for (CachedJwk cached : activeKeys) {
            JWK jwk = cached.jwk;
            if (!"RSA".equals(jwk.getKeyType().getValue())) {
                continue;
            }
            if (kid != null && !kid.equals(jwk.getKeyID())) {
                continue;
            }

            RSAPublicKey publicKey = jwk.toRSAKey().toRSAPublicKey();
            JWSVerifier verifier = new RSASSAVerifier(publicKey);
            if (signedJWT.verify(verifier)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 刷新远程 JWK,并合并到本地缓存,同时清理过期旧密钥。
     */
    private synchronized void refreshJwkSet() {
        try {
            JWKSet newSet = JWKSet.load(jwksURL);
            long now = System.currentTimeMillis();

            // 清理过期旧密钥
            cachedJwks.removeIf(c -> now - c.loadTime > JWKS_KEY_MAX_AGE);

            // 添加新密钥(去重 kid)
            for (JWK jwk : newSet.getKeys()) {
                boolean exists = cachedJwks.stream().anyMatch(c -> c.jwk.getKeyID().equals(jwk.getKeyID()));
                if (!exists) {
                    cachedJwks.add(new CachedJwk(jwk, now));
                }
            }

            lastJwkLoadTime = now;

        } catch (IOException | ParseException e) {
            log.warn("Failed to refresh JWKS: {}", e.getMessage());
        }
    }

    /**
     * 返回 401 未授权响应,并在 header 中添加跳转授权前缀。
     *
     * @param response 当前 HTTP 响应对象
     */
    void sendUnAuthResponse(HttpClassicServerResponse response) {
        response.statusCode(401);
        response.headers().add("fit-redirect-to-prefix", this.loginBridgeUrlPrefix);
        response.send();
    }
}

OAuth客户端接口

/**
 * 用户认证控制器(OAuth2)。
 * 提供登录、注销和回调处理接口,实现基于 OAuth2 的认证流程,
 * 并将 Access Token 设置到 HttpOnly Cookie 中。
 *
 * @author Maiicy
 * @since 2025-09-22
 */
@Component
@Validated
@RequestMapping(path = "v1/api/auth", group = "用户认证相关(OAuth)")
public class AuthController {
		/** 略部分代码 **/

    /**
     * 处理 OAuth2 回调请求。
     * <p>
     * 接收授权服务器返回的重定向请求,解析 Authorization Code,
     * 使用该 Code 向 Token Endpoint 请求 Access Token,然后将其设置到 HttpOnly Cookie 中,
     * 最后重定向到首页。
     *
     * @param request 当前的 HTTP 请求对象,包含查询参数和请求信息
     * @param response 当前的 HTTP 响应对象,用于写入 Set-Cookie 和重定向头
     * @throws Exception 解析 URI、发送 Token 请求或处理响应时可能抛出的异常
     */
    @GetMapping("/callback")
    @ResponseStatus(HttpResponseStatus.MOVED_PERMANENTLY)
    public void handleCallback(HttpClassicServerRequest request, HttpClassicServerResponse response) throws Exception {
        String scheme = request.isSecure() ? "https" : "http";
        String host = request.host();
        String path = request.path();
        String query = request.queries().queryString();
        String fullUrl = scheme + "://" + host + path + (!query.isEmpty() ? "?" + query : "");

        URI callbackURI = new URI(fullUrl);
        AuthorizationResponse authResp = AuthorizationResponse.parse(callbackURI);
        if (!authResp.indicatesSuccess()) {
            // throw new BadRequestException("Authorization failed: invalid response.");
            response.statusCode(400);
            response.send();
            return;
        }
        AuthorizationSuccessResponse successResponse = (AuthorizationSuccessResponse) authResp;
        if (successResponse.getAuthorizationCode() == null) {
            // throw new BadRequestException("Authorization code is missing.");
            response.statusCode(400);
            response.send();
            return;
        }

        AuthorizationCode code = successResponse.getAuthorizationCode();
        AuthorizationGrant codeGrant = new AuthorizationCodeGrant(code, this.redirectUri);
        TokenRequest tokenRequest = new TokenRequest.Builder(this.tokenEndpoint, this.clientAuth, codeGrant).build();
        HTTPResponse httpResponse = tokenRequest.toHTTPRequest().send();
        TokenResponse tokenResponse = TokenResponse.parse(httpResponse);

        if (!tokenResponse.indicatesSuccess()) {
            response.statusCode(400);
            response.send();
            return;
        }

        Tokens tokens = tokenResponse.toSuccessResponse().getTokens();
        String accessToken = tokens.getAccessToken().getValue();
        String refreshToken = tokens.getRefreshToken().getValue();

        Cookie cookie1 =
                Cookie.builder().name("access-token").value(accessToken).httpOnly(true).secure(true).path("/").build();
        Cookie cookie2 = Cookie.builder()
                .name("refresh-token")
                .value(refreshToken)
                .httpOnly(true)
                .secure(true)
                .path("/")
                .build();
        // 3.5.4 版本之后应当添加   .sameSite("None")

        // 3.5.4 版本之前 response 内写 cookie 框架还暂时不会把他改为 Set-Cookie, 因此暂时先手动写入响应头,等新版fit-framework后修改
        response.headers().add("Set-Cookie", toSetCookieHeaderValue(cookie1));
        response.headers().add("Set-Cookie", toSetCookieHeaderValue(cookie2));

        try {
            String redirectUrl = decryptState(authResp.getState().toString());
            response.headers().set("Location", redirectUrl);
        } catch (JOSEException e) {
            response.headers().set("Location", "/");
        }
    }

    @GetMapping("/redirect")
    @ResponseStatus(HttpResponseStatus.FOUND)
    public void handleRedirect(HttpClassicServerResponse response, @RequestParam("redirect_uri") String url)
            throws Exception {

        URI target = new URI(url);

        boolean sameHost = this.redirectUri.getHost().equalsIgnoreCase(target.getHost());
        boolean sameScheme = this.redirectUri.getScheme().equalsIgnoreCase(target.getScheme());
        boolean samePort = (this.redirectUri.getPort() == -1
                ? this.redirectUri.toURL().getDefaultPort()
                : this.redirectUri.getPort()) == (target.getPort() == -1
                ? target.toURL().getDefaultPort()
                : target.getPort());

        if (!(sameHost && sameScheme && samePort)) {
            // throw new BadRequestException("Redirect URI must be same-domain as registered redirectUri");
            response.statusCode(400);
            response.send();
            return;
        }

        String state = encryptState(url);
        AuthorizationRequest authRequest = new AuthorizationRequest.Builder(new ResponseType(ResponseType.Value.CODE),
                this.clientId).scope(new Scope("read"))
                .state(new State(state))
                .redirectionURI(this.redirectUri)
                .endpointURI(this.loginEndpoint)
                .build();

        response.headers().add("Location", authRequest.toURI().toString());
    }

    @GetMapping("/refresh-token")
    @ResponseStatus(HttpResponseStatus.OK)
    public void handleRefreshToken(HttpClassicServerRequest request, HttpClassicServerResponse response)
            throws Exception {
        if (request.cookies().get("refresh-token").isEmpty()) {
            response.statusCode(400);
            response.send();
            return;
        }
        String refreshToken = request.cookies().get("refresh-token").get().value();

        RefreshTokenGrant refreshGrant = new RefreshTokenGrant(new RefreshToken(refreshToken));
        TokenRequest tokenRequest = new TokenRequest.Builder(this.tokenEndpoint, this.clientAuth, refreshGrant).build();
        HTTPResponse httpResponse = tokenRequest.toHTTPRequest().send();

        TokenResponse tokenResponse = TokenResponse.parse(httpResponse);
        if (!tokenResponse.indicatesSuccess()) {
            // throw new BadRequestException("Failed to refresh access token");
            response.statusCode(400);
            response.send();
            return;
        }

        AccessTokenResponse successResponse = tokenResponse.toSuccessResponse();
        AccessToken newAccessToken = successResponse.getTokens().getAccessToken();
        String accessToken = newAccessToken.getValue();

        Cookie cookie =
                Cookie.builder().name("access-token").value(accessToken).httpOnly(true).secure(true).path("/").build();

        response.headers().add("Set-Cookie", toSetCookieHeaderValue(cookie));
    }

    /**
     * 处理登录请求。
     * <p>
     * 返回 401 Unauthorized 状态,并在自定义响应头中指明 OAuth2 授权端点。
     * 前端可根据该头信息发起 OAuth2 授权流程。
     *
     * @param response 当前的 HTTP 响应对象,用于写入状态码和自定义跳转头
     */
    @PostMapping("/login")
    @ResponseStatus(HttpResponseStatus.UNAUTHORIZED)
    public void handleLogin(HttpClassicServerResponse response) {
        response.headers().add("fit-redirect-to-prefix", loginBridgeUrlPrefix);
    }

    /**
     * 处理注销请求。
     * <p>
     * 清除客户端的 Access Token Cookie,将其 Max-Age 设置为 0。
     *
     * @param response 当前的 HTTP 响应对象,用于写入 Set-Cookie 头
     */
    @PostMapping("/logout")
    @ResponseStatus(HttpResponseStatus.OK)
    public void handleLogout(HttpClassicServerRequest request, HttpClassicServerResponse response) {
        Cookie cookie1 =
                Cookie.builder().name("access-token").value("").httpOnly(true).secure(true).path("/").maxAge(0).build();
        Cookie cookie2 = Cookie.builder()
                .name("refresh-token")
                .value("")
                .httpOnly(true)
                .secure(true)
                .path("/")
                .maxAge(0)
                .build();
        response.headers().add("Set-Cookie", toSetCookieHeaderValue(cookie1));
        response.headers().add("Set-Cookie", toSetCookieHeaderValue(cookie2));

        if (request.cookies().get("refresh-token").isPresent()) {
            String refreshTokenValue = request.cookies().get("refresh-token").get().value();
            try {
                RefreshToken refreshToken = new RefreshToken(refreshTokenValue);
                TokenRevocationRequest revocationRequest =
                        new TokenRevocationRequest(this.revokeEndpoint, this.clientAuth, refreshToken);
                revocationRequest.toHTTPRequest().send();
            } catch (Exception e) {
                System.out.println(e.getMessage());
            }
        }
    }
}

功能拓展(单点登出 + 跳回原URL)

单点登出

将SAS的签发存储改为数据库存储,建立其使用的表(官方文档中提供)

后定制清除所有 Refresh-Token 的方法给登出Handler调用即可。

客户端会在 access-token 过期之后自动申请更新,如果refresh被改为无效就完成登出。

跳回原URL

在OAuth2 中跳回原URL的信息都是存储在state

项目是前后端分离的:

  • state因为涉及加密,因此应该交给后端
  • 但原URL的信息只有前端拥有

因此设计 /redirect 接口,在前端要跳转的时候,将当前的URL信息发送给后端接口,后端接口进行安全验证后,加密成为state进行OAuth2的传输。

state会在callback接口进行传回,因此直接再解密进行跳转即可。