背景与目标
在 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接口进行传回,因此直接再解密进行跳转即可。