1. 概述

本教程将演示如何为OAuth Spring Security应用添加登出功能。我们将探讨两种实现方式:首先基于Keycloak的OAuth应用实现登出(参考使用OAuth2创建REST API),然后通过Zuul代理实现登出。

本文使用Spring Security 5的OAuth栈。若需使用Spring Security OAuth遗留栈,请参考旧版文章:OAuth安全应用中的登出(遗留栈版)

2. 前端应用实现登出

由于访问令牌由授权服务器管理,必须在该层级使其失效。具体步骤因授权服务器而异。以Keycloak为例,根据其文档,可直接通过浏览器重定向到以下URL实现登出:

http://auth-server/auth/realms/{realm-name}/protocol/openid-connect/logout?redirect_uri=encodedRedirectUri

除传递重定向URI外,还需向Keycloak的登出接口提交id_token_hint。该参数需携带编码后的id_token值。

参考保存access_token的方式,我们同样保存id_token

saveToken(token) {
  var expireDate = new Date().getTime() + (1000 * token.expires_in);
  Cookie.set("access_token", token.access_token, expireDate);
  Cookie.set("id_token", token.id_token, expireDate);
  this._router.navigate(['/']);
}

关键点:若要在授权服务器响应中获取ID令牌,需在scope参数中包含openid

现在看登出流程的具体实现。修改App Service中的logout函数:

logout() {
  let token = Cookie.get('id_token');
  Cookie.delete('access_token');
  Cookie.delete('id_token');
  let logoutURL = "http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/logout?
    id_token_hint=" + token + "&post_logout_redirect_uri=" + this.redirectUri;

  window.location.href = logoutURL;
}

除重定向外,还需清除从授权服务器获取的访问令牌和ID令牌。上述代码先删除令牌,再将浏览器重定向到Keycloak的登出接口。

注意:重定向URI设为http://localhost:8089/(应用统一入口),登出后将返回该页面。当前会话对应的访问令牌、ID令牌和刷新令牌的删除操作由授权服务器完成(本例中浏览器应用未保存刷新令牌)。

3. 通过Zuul代理实现登出

处理刷新令牌一文中,我们已搭建了基于Zuul代理和自定义过滤器的令牌刷新机制。现在为其添加登出功能。

本次将使用Keycloak的另一组API:通过POST请求调用logout接口实现非浏览器方式的会话登出,而非前述的URL重定向。

3.1 定义登出路由

首先在application.yml中添加代理路由:

zuul:
  routes:
    #...
    auth/refresh/revoke:
      path: /auth/refresh/revoke/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/logout
    
    #auth/refresh路由

⚠️ 重要:子路由auth/refresh/revoke必须定义在主路由auth/refresh之前,否则Zuul会始终匹配主路由。

使用子路由而非独立路由的原因是:需要访问路径受限的refreshToken cookie(该cookie路径设为/auth/refresh及其子路径,详见令牌提取)。

3.2 POST调用授权服务器的*/logout*

增强CustomPreZuulFilter实现,拦截/auth/refresh/revoke请求并添加必要参数:

@Component 
public class CustomPostZuulFilter extends ZuulFilter { 
    //... 
    @Override 
    public Object run() { 
        //...
        if (requestURI.contains("auth/refresh/revoke")) {
            String cookieValue = extractCookie(req, "refreshToken");
            String formParams = String.format("client_id=%s&client_secret=%s&refresh_token=%s", 
              CLIENT_ID, CLIENT_SECRET, cookieValue);
            bytes = formParams.getBytes("UTF-8");
        }
        //...
    }
}

此处提取refreshToken cookie并组装为表单参数(与刷新令牌请求类似,但无需grant_type)。

3.3 移除刷新令牌

通过重定向方式登出时,关联的刷新令牌会被授权服务器自动失效。但本方案中,客户端的httpOnly cookie仍会保留。由于无法通过JavaScript删除,需在服务端处理。

修改CustomPostZuulFilter,拦截/auth/refresh/revoke请求时移除refreshToken cookie

@Component
public class CustomPostZuulFilter extends ZuulFilter {
    //...
    @Override
    public Object run() {
        //...
        String requestMethod = ctx.getRequest().getMethod();
        if (requestURI.contains("auth/refresh/revoke")) {
            Cookie cookie = new Cookie("refreshToken", "");
            cookie.setMaxAge(0);
            ctx.getResponse().addCookie(cookie);
        }
        //...
    }
}

3.4 移除Angular客户端的访问令牌

除刷新令牌外,还需清除客户端的access_token cookie。在Angular控制器中添加方法:

logout() {
  let headers = new HttpHeaders({
    'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'});
  
  this._http.post('auth/refresh/revoke', {}, { headers: headers })
    .subscribe(
      data => {
        Cookie.delete('access_token');
        window.location.href = 'http://localhost:8089/';
        },
      err => alert('Could not logout')
    );
}

该方法将绑定到登出按钮:

<a class="btn btn-default pull-right"(click)="logout()" href="#">Logout</a>

4. 总结

本教程深入探讨了OAuth安全应用的用户登出实现及令牌失效机制。完整示例代码可在GitHub获取。


原始标题:Logout in an OAuth Secured Application