知識庫 / Spring / Spring Security RSS 訂閱

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

Spring Security
HongKong
4
02:52 PM · Dec 06 ,2025

1. 概述

本文將繼續探討 OAuth2 授權碼流程,該流程我們在上一篇文章中開始搭建,重點將集中在如何處理 Angular 應用中的刷新令牌上。我們還將利用 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() 函數來獲取 Access Token:

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 中,這並不是一個安全的方式來處理這種情況。 讓我們看看如何避免這樣做。

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<String, List> 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 等查詢參數,這些參數是我們的授權服務器需要用來將我們引導到其登錄頁面並返回 Code 的所有內容。

此外,請注意 shouldFilter() 方法。 只有包含以下三個 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 添加 Same-Site 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);
        };
    }
}

我們在這裏將該屬性設置為 嚴格,以嚴格阻止跨站點 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;
}

以下是我們的自定義HttpServletRequest——用於將請求體以及帶有所需表單參數的字節轉換為字節發送:

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. 將刷新令牌存儲在 Cookie 中

讓我們開始一些有趣的內容。

我們計劃讓客户端以 Cookie 的形式獲取刷新令牌。

我們將向我們的 Zuul 後端過濾器添加功能,從響應的 JSON 響應主體中提取刷新令牌,並將其設置為 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 days
            ctx.getResponse().addCookie(cookie);
        }
        ctx.setResponseBody(responseBody);
    }
    ...
}

如您所見,我們在此為我們的 Zuul 後過濾器添加了一個條件,用於讀取響應並提取 Refresh Token,應用於路由 auth/tokenauth/refresh。 我們之所以這樣做,是因為 Authorization Server 在獲取 Access Token 和 Refresh Token 時本質上會發送相同的 payload。

然後我們從 JSON 響應中刪除了 refresh_token,以確保它永遠不會對前端暴露,除非通過 cookie。

另外需要注意的是,我們設置了 cookie 的最大過期時間為 30 天——這與 Token 的過期時間相匹配。

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

現在我們已經獲得了 Refresh Token 並將其存儲在 Cookie 中,當前端 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,而不是將我們之前保存到 cookie 中的 authorization_code

在獲取響應後,它再次經過了與之前第 7 節中看到的相同的轉換,發生在 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

這將觸發我們在 pom.xml 中定義的 frontend-maven-plugin 構建 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.