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 – 將其映射到授權服務器的相應路徑,用於其登錄頁面的資源(css 和 js)
- 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_type、scope、client_id 和 redirect_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/token 和 auth/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)包含在複製過程中。
在第二步中,我們需要運行我們的 SpringBootApplication 類 UiApplication。 我們的客户端應用程序將在 application.yml 中指定的端口 8089 上運行。