OAuth2 為 Spring REST API – 在 Angular 中處理刷新令牌

Spring Security
Remote
1
08:17 PM · Nov 29 ,2025

1. 概述

在本教程中,我們將繼續探索我們在上一篇文章中開始構建的 OAuth2 授權碼流程,並重點關注如何在 Angular 應用中處理 Refresh Token。 我們還將使用 Zuul 代理。

我們將使用 Spring Security 5 中的 OAuth 棧。 如果您想使用 Spring Security OAuth 遺留棧,請查看 OAuth2 for a Spring REST API – Handle the Refresh Token in AngularJS (legacy OAuth stack) 文章。

2. 訪問令牌過期時間

首先,請記住,客户端是通過兩步授權碼 grant 類型獲取訪問令牌。第一步,我們獲取授權碼。第二步,我們實際獲取訪問令牌。

我們的訪問令牌存儲在 cookie 中,其過期時間取決於令牌本身的過期時間:

var expireDate = new Date().getTime() + (1000 * token.expires_in);
Cookie.set("access_token", token.access_token, expireDate);

重要的是要理解的是,該 cookie 僅用於存儲,它不會驅動 OAuth2 流程中的任何其他操作。例如,瀏覽器永遠不會自動將 cookie 發送到服務器,因此我們在這裏得到了保障。

但是請注意我們如何定義這個 retrieveToken() 函數來獲取訪問令牌:

retrieveToken(code) {
  let params = new URLSearchParams();
  params.append('grant_type','authorization_code');
  params.append('client_id', this.clientId);
  params.append('client_secret', 'newClientSecret');
  params.append('redirect_uri', this.redirectUri);
  params.append('code',code);

  let headers =
    new HttpHeaders({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'});

  this._http.post('http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token',
    params.toString(), { headers: headers })
    .subscribe(
      data => this.saveToken(data),
      err => alert('Invalid Credentials'));
}

我們在 params 中發送了 client secret,這並不是一種安全的方式來處理它。讓我們看看如何避免這樣做。

3. 代理 (Proxy)

因此,我們將在前端應用程序中運行一個 Zuul 代理,它基本上位於前端客户端和授權服務器之間。所有敏感信息都將在該層進行處理。

前端客户端現在將作為 Boot 應用程序託管,以便我們可以使用 Spring Cloud Zuul starter 無縫連接到我們的嵌入式 Zuul 代理。

如果您想回顧 Zuul 的基本知識,請閲讀主要的 Zuul 文章。

現在,讓我們配置代理的路由:

zuul:
  routes:
    auth/code:
      path: /auth/code/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/auth
    auth/token:
      path: /auth/token/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token
    auth/refresh:
      path: /auth/refresh/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token
    auth/redirect:
      path: /auth/redirect/**
      sensitiveHeaders:
      url: http://localhost:8089/
    auth/resources:
      path: /auth/resources/**
      sensitiveHeaders:
      url: http://localhost:8083/auth/resources/

我們設置了以下路由來處理:

  • auth/code – 獲取授權碼並將其保存到 cookie 中
  • auth/redirect – 處理到授權服務器登錄頁面的重定向
  • auth/resources – 將其映射到授權服務器的相應路徑,用於其登錄頁面的資源(cssjs
  • auth/token – 獲取訪問令牌,從有效負載中刪除 refresh_token 並將其保存到 cookie 中
  • auth/refresh – 獲取刷新令牌,從有效負載中刪除它並將其保存到 cookie 中

有趣的是,我們僅將流量代理到授權服務器,而沒有代理任何其他內容。我們真正需要代理是在客户端獲取新令牌時才出現。

接下來,讓我們逐一查看這些內容。

4. 使用 Zuul 預過濾器獲取代碼

代理的第一個使用是簡單的 – 我們設置一個請求來獲取授權碼:

@Component
public class CustomPreZuulFilter extends ZuulFilter {
    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest req = ctx.getRequest();
        String requestURI = req.getRequestURI();
        if (requestURI.contains("auth/code")) {
            Map> params = ctx.getRequestQueryParams();
            if (params == null) {
                params = Maps.newHashMap();
            }
            params.put("response_type", Lists.newArrayList(new String[] { "code" }));
            params.put("scope", Lists.newArrayList(new String[] { "read" }));
            params.put("client_id", Lists.newArrayList(new String[] { CLIENT_ID }));
            params.put("redirect_uri", Lists.newArrayList(new String[] { REDIRECT_URL }));
            ctx.setRequestQueryParams(params);
        }
        return null;
    }

    @Override
    public boolean shouldFilter() {
        boolean shouldfilter = false;
        RequestContext ctx = RequestContext.getCurrentContext();
        String URI = ctx.getRequest().getRequestURI();

        if (URI.contains("auth/code") || URI.contains("auth/token") ||
          URI.contains("auth/refresh")) {
            shouldfilter = true;
        }
        return shouldfilter;
    }

    @Override
    public int filterOrder() {
        return 6;
    }

    @Override
    public String filterType() {
        return "pre";
    }
}

我們使用 pre 類型的過濾器來在將請求傳遞給下一個過濾器之前對其進行處理。

在過濾器的 run() 方法中,我們為 response_typescopeclient_idredirect_uri 添加查詢參數 – 授權服務器需要的一切,以便將我們引導到登錄頁面並返回一個代碼。

請注意 shouldFilter() 方法。我們僅過濾具有 3 個 URI 的請求,其他請求不會傳遞到 run 方法。

5. 將代碼存儲在 Cookie 中使用Zuul 後端過濾器

我們計劃這樣做是保存代碼為 Cookie,以便將其發送到授權服務器以獲取訪問令牌。代碼作為請求 URL 中的查詢參數存在,授權服務器在用户登錄後重定向到該 URL 中。

我們將設置一個 Zuul 後端過濾器來提取此代碼並將其存儲在 Cookie 中。 這是一個非標準的 Cookie,而是一個 受保護的、僅 HTTP 的 Cookie,具有非常有限的路徑 (</auth/token)

@Component
public class CustomPostZuulFilter extends ZuulFilter {
    private ObjectMapper mapper = new ObjectMapper();

    @Override
    public Object run() {
        RequestContext ctx = RequestContext.getCurrentContext();
        try {
            Map<String, List> params = ctx.getRequestQueryParams();

            if (requestURI.contains("auth/redirect")) {
                Cookie cookie = new Cookie("code", params.get("code").get(0));
                cookie.setHttpOnly(true);
                cookie.setPath(ctx.getRequest().getContextPath() + "/auth/token");
                ctx.getResponse().addCookie(cookie);
            }
        } catch (Exception e) {
            logger.error("Error occured in zuul post filter", e);
        }
        return null;
    }

    @Override
    public boolean shouldFilter() {
        boolean shouldfilter = false;
        RequestContext ctx = RequestContext.getCurrentContext();
        String URI = ctx.getRequest().getRequestURI();

        if (URI.contains("auth/redirect") || URI.contains("auth/token") || URI.contains("auth/refresh")) {
            shouldfilter = true;
        }
        return shouldfilter;
    }

    @Override
    public int filterOrder() {
        return 10;
    }

    @Override
    public String filterType() {
        return "post";
    }
}

為了增加對 CSRF 攻擊的額外保護,我們將為所有 Cookie 添加 相同站點 Cookie 標頭

為此,我們將創建一個配置類:

@Configuration
public class SameSiteConfig implements WebMvcConfigurer {
    @Bean
    public TomcatContextCustomizer sameSiteCookiesConfig() {
        return context -> {
            final Rfc6265CookieProcessor cookieProcessor = new Rfc6265CookieProcessor();
            cookieProcessor.setSameSiteCookies(SameSiteCookies.STRICT.getValue());
            context.setCookieProcessor(cookieProcessor);
        };
    }
}

在這裏,我們將屬性設置為 </strict>,以便任何跨站點 Cookie 傳輸將被嚴格拒絕。

6. 從 Cookie 中獲取和使用代碼現在我們已經將代碼存儲在 Cookie 中,當前端 Angular 應用程序嘗試觸發 Token 請求時,它將向 /auth/token 發送請求,瀏覽器自然會發送該 Cookie。

因此,我們將在 pre 過濾器中添加另一個條件,該過濾器用於 從 Cookie 中提取代碼並將其與其它表單參數一起發送以獲取 Token

public Object run() {
    RequestContext ctx = RequestContext.getCurrentContext();
    ...
    else if (requestURI.contains("auth/token"))) {
        try {
            String code = extractCookie(req, "code");
            String formParams = String.format(
              "grant_type=%s&client_id=%s&client_secret=%s&redirect_uri=%s&code=%s",
              "authorization_code", CLIENT_ID, CLIENT_SECRET, REDIRECT_URL, code);

            byte[] bytes = formParams.getBytes("UTF-8");
            ctx.setRequest(new CustomHttpServletRequest(req, bytes));
        } catch (IOException e) {
            e.printStackTrace();
        }
    } 
    ...
}

private String extractCookie(HttpServletRequest req, String name) {
    Cookie[] cookies = req.getCookies();
    if (cookies != null) {
        for (int i = 0; i < cookies.length; i++) {
            if (cookies[i].getName().equalsIgnoreCase(name)) {
                return cookies[i].getValue();
            }
        }
    }
    return null;
}

以下是我們的 CustomHttpServletRequest – 用於將包含所需表單參數的請求主體轉換為字節的

public class CustomHttpServletRequest extends HttpServletRequestWrapper {

    private byte[] bytes;

    public CustomHttpServletRequest(HttpServletRequest request, byte[] bytes) {
        super(request);
        this.bytes = bytes;
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        return new ServletInputStreamWrapper(bytes);
    }

    @Override
    public int getContentLength() {
        return bytes.length;
    }

    @Override
    public long getContentLengthLong() {
        return bytes.length;
    }
	
    @Override
    public String getMethod() {
        return "POST";
    }
}

這將使我們從授權服務器的響應中獲取訪問令牌。接下來,我們將看到如何轉換響應。

7. 將 Refresh Token 存儲在 Cookie 中

進入有趣的部分。

我們在這裏的計劃是讓客户端將 Refresh Token 作為 Cookie 獲取。

我們將添加到我們的 Zuul 後過濾器中,從響應的 JSON 中提取 Refresh Token 並將其設置為 Cookie。 這是一個安全的、僅限 HTTP 的 Cookie,具有非常有限的路徑 (/auth/refresh):

public Object run() {
...
    else if (requestURI.contains("auth/token") || requestURI.contains("auth/refresh")) {
        InputStream is = ctx.getResponseDataStream();
        String responseBody = IOUtils.toString(is, "UTF-8");
        if (responseBody.contains("refresh_token")) {
            Map<String, Object> responseMap = mapper.readValue(responseBody, 
              new TypeReference<Map<String, Object>>() {});
            String refreshToken = responseMap.get("refresh_token").toString();
            responseMap.remove("refresh_token");
            responseBody = mapper.writeValueAsString(responseMap);

            Cookie cookie = new Cookie("refreshToken", refreshToken);
            cookie.setHttpOnly(true);
            cookie.setPath(ctx.getRequest().getContextPath() + "/auth/refresh");
            cookie.setMaxAge(2592000); // 30 天
            ctx.getResponse().addCookie(cookie);
        }
        ctx.setResponseBody(responseBody);
    }
    ...
}

正如我們所看到的,我們在此添加了一個條件到我們的 Zuul 後過濾器中,以讀取響應並提取 Refresh Token 用於 routes auth/tokenauth/refresh。 我們之所以對這兩個進行相同的操作,是因為授權服務器本質上會發送相同的 payload,在獲取 Access Token 和 Refresh Token 時。

然後我們從 JSON 響應中刪除了 refresh_token,以確保它永遠無法被前端在 Cookie 之外訪問。

另一個需要注意的點是,我們設置了 Cookie 的最大年齡為 30 天 – 這樣與 Token 的過期時間相匹配。

8. 從 Cookie 中獲取並使用 Refresh Token

現在我們已經有了 Cookie 中的 Refresh Token,當前端 Angular 應用嘗試觸發 Token 刷新時,它將向 /auth/refresh 發送請求,瀏覽器自然也會發送該 Cookie。

因此,我們將在 pre 過濾器中添加另一個條件,從 Cookie 中提取 Refresh Token 並將其作為 HTTP 參數轉發——以便請求有效:

public Object run() {
    RequestContext ctx = RequestContext.getCurrentContext();
    ...
    else if (requestURI.contains("auth/refresh"))) {
        try {
            String token = extractCookie(req, "token");                       
            String formParams = String.format(
              "grant_type=%s&client_id=%s&client_secret=%s&refresh_token=%s", 
              "refresh_token", CLIENT_ID, CLIENT_SECRET, token);
 
            byte[] bytes = formParams.getBytes("UTF-8");
            ctx.setRequest(new CustomHttpServletRequest(req, bytes));
        } catch (IOException e) {
            e.printStackTrace();
        }
    } 
    ...
}

這與我們首次獲取 Access Token 時所做的事情類似。但請注意,表單主體有所不同。我們現在將 grant_type 設置為 refresh_token,而不是 authorization_code,並附帶我們之前保存的 Cookie 中的 Token

在獲取響應後,它將再次通過 pre 過濾器中早先看到的相同轉換。

9. 從 Angular 刷新訪問令牌

最後,讓我們修改我們的簡單前端應用程序,並實際利用刷新令牌:

這是我們的函數 refreshAccessToken()

refreshAccessToken() {
  let headers = new HttpHeaders({
    'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'});
  this._http.post('auth/refresh', {}, {headers: headers })
    .subscribe(
      data => this.saveToken(data),
      err => alert('Invalid Credentials')
    );
}

請注意,我們只是使用了現有的 saveToken() 函數——並只是將不同的輸入傳遞給它。

請注意,我們沒有為 refresh_token 添加任何表單參數——因為這將會由 Zuul 過濾器處理

10. 運行前端

由於我們的前端 Angular 客户端現在作為 Boot 應用程序託管,因此運行它將與之前略有不同。

第一步相同。我們需要構建 App:

mvn clean install

這將觸發 frontend-maven-plugin,該插件已定義在我們的 pom.xml 中,以構建 Angular 代碼並將 UI 藝術品複製到 target/classes/static 文件夾。 此過程會覆蓋 src/main/resources 目錄中任何其他內容。 因此,我們需要確保將此目錄中任何所需資源(例如 application.yml)包含在複製過程中。

在第二步中,我們需要運行我們的 SpringBootApplicationUiApplication。 我們的客户端應用程序將在 application.yml 中指定的端口 8089 上運行。

11. 結論在本 OAuth2 教程中,我們學習瞭如何在 Angular 客户端應用程序中存儲 Refresh Token,如何刷新過期的 Access Token,以及如何利用 Zuul 代理來實現這一切。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.