1. 개요

이 예제에서, 우리는 OAuth2를 인증 코드는 우리가 함께 퍼팅 시작했다 흐름을 탐구 계속 것입니다 우리의 이전 기사우리는 각도 응용 프로그램에서 새로 고침 토큰을 처리하는 방법에 초점을 맞출 것입니다. 우리는 또한 Zuul 프록시를 사용할 것입니다.

Spring Security 5에서 OAuth 스택 을 사용합니다. Spring Security OAuth 레거시 스택을 사용하려면 이전 기사: Spring REST API용 OAuth2 – AngularJS에서 새로 고침 토큰 처리(레거시 OAuth 스택)

2. 액세스 토큰 만료

먼저 클라이언트가 두 단계로 인증 코드 부여 유형을 사용하여 액세스 토큰을 얻었다는 것을 기억하십시오. 첫 번째 단계 에서 인증 코드를 얻습니다 . 그리고 두 번째 단계에서는 실제로 Access Token을 얻습니다 .

우리의 액세스 토큰은 토큰 자체가 만료되는 시점에 따라 만료되는 쿠키에 저장됩니다.

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

이해하는 것이 중요한 것은 쿠키 자체가 저장용으로만 사용 되며 OAuth2 흐름에서 다른 어떤 것도 구동하지 않는다는 것입니다. 예를 들어 브라우저는 요청과 함께 쿠키를 서버에 자동으로 보내지 않으므로 여기에서 Security을 유지합니다.

그러나 액세스 토큰을 얻기 위해 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에 클라이언트 비밀을 보내고 있는데, 이것은 실제로 이것을 처리하는 안전한 방법이 아닙니다. 이를 방지할 수 있는 방법을 살펴보겠습니다.

3. 프록시

따라서 이제 프론트엔드 애플리케이션에서 실행되고 기본적으로 프론트엔드 클라이언트와 인증 서버 사이에 있는 Zuul 프록시를 갖게 될 것 입니다. 모든 민감한 정보는 이 계층에서 처리됩니다.

프런트 엔드 클라이언트는 이제 Boot 애플리케이션으로 호스팅되므로 Spring Cloud Zuul 스타터를 사용하여 임베디드 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 – 인증 코드를 가져와 쿠키에 저장
  • auth/redirect – Authorization Server의 로그인 페이지로 리디렉션을 처리합니다.
  • auth/resources – 로그인 페이지 리소스( cssjs )에 대한 권한 부여 서버의 해당 경로에 매핑합니다.
  • auth/token – 액세스 토큰을 가져 오고, 페이로드에서 refresh_token제거 하고 쿠키에 저장합니다.
  • auth/refresh – 새로 고침 토큰을 가져와 페이로드에서 제거하고 쿠키에 저장합니다.

여기서 흥미로운 점은 인증 서버에 대한 트래픽만 프록시하고 다른 것은 아니라는 것입니다. 클라이언트가 새 토큰을 얻을 때만 프록시가 필요합니다.

다음으로 이 모든 것을 하나씩 살펴보겠습니다.

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_type , scope , client_idredirect_uri 대한 쿼리 매개변수를 추가합니다. 이는 Authorization Server가 로그인 페이지로 이동하고 코드를 다시 보내는 데 필요한 모든 것입니다.

또한 shouldFilter() 메서드에 유의하십시오 . 우리는 언급된 3개의 URI로 요청을 필터링할 뿐이고 나머지는 실행 방법 을 거치지 않습니다 .

5. Zuul 포스트 필터를 사용 하여 쿠키에 코드 넣기

여기서 우리가 할 계획은 코드를 쿠키로 저장하여 액세스 토큰을 얻기 위해 권한 부여 서버로 보낼 수 있도록 하는 것입니다. 코드는 로그인 후 Authorization Server가 우리를 리디렉션하는 요청 URL에 쿼리 매개변수로 존재합니다.

Zuul 사후 필터를 설정하여 이 코드를 추출하고 쿠키에 설정합니다. 이것은 일반 쿠키가 아니라 매우 제한된 경로( /auth/token )를 가진 안전한 HTTP 전용 쿠키입니다 .

@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 공격에 대한 추가 보호 계층을 추가하기 위해 Same-Site 쿠키 헤더를 모든 쿠키에 추가 합니다 .

이를 위해 구성 클래스를 생성합니다.

@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 로 설정하여 쿠키의 사이트 간 전송이 엄격하게 보류됩니다.

6. 쿠키에서 코드 가져오기 및 사용

이제 쿠키에 코드가 있으므로 프런트 엔드 Angular 애플리케이션이 토큰 요청을 트리거하려고 할 때 /auth/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";
    }
}

이렇게 하면 응답에서 Authorization Server의 액세스 토큰을 얻을 수 있습니다. 다음으로 응답을 변환하는 방법을 살펴보겠습니다.

7. 쿠키에 새로고침 토큰 넣기

재미있는 것들로.

여기서 우리가 할 계획은 클라이언트가 새로 고침 토큰을 쿠키로 받도록 하는 것입니다.

응답의 JSON 본문에서 새로 고침 토큰을 추출하고 쿠키에 설정하기 위해 Zuul 사후 필터에 추가합니다. 이것은 매우 제한된 경로( /auth/refresh )를 가진 안전한 HTTP 전용 쿠키입니다 .

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 사후 필터에 조건을 추가하여 응답을 읽고 auth/tokenauth/refresh 경로에 대한 Refresh Token을 추출했습니다 . Access Token과 Refresh Token을 얻는 동안 Authorization Server가 본질적으로 동일한 페이로드를 보내기 때문에 우리는 두 가지에 대해 똑같은 일을 하고 있습니다.

그런 다음 쿠키 외부의 프런트 엔드에 액세스할 수 없도록 JSON 응답에서 refresh_token제거 했습니다.

여기서 주목해야 할 또 다른 점은 쿠키의 최대 기간을 30일로 설정한다는 것입니다. 이는 토큰의 만료 시간과 일치하기 때문입니다.

8. 쿠키에서 새로 고침 토큰 가져오기 및 사용

이제 쿠키에 새로 고침 토큰이 있으므로 프런트 엔드 Angular 응용 프로그램이 토큰 새로 고침을 트리거하려고 할/auth/refresh 에서 요청을 보내 므로 브라우저는 당연히 해당 쿠키를 보냅니다.

이제 프록시 사전 필터에 쿠키에서 새로 고침 토큰을 추출하고 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을 얻었을 때 했던 것과 유사합니다. 그러나 양식 본문이 다릅니다. 이제 이전에 쿠키에 저장한 토큰과 함께 authorization_code 대신 refresh_tokengrant_type전송합니다 .

응답을 얻은 후 이전 섹션 7에서 본 것과 같은 사전 필터 에서 동일한 변환을 다시 거칩니다 .

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 애플리케이션으로 호스팅되므로 실행하면 이전과 약간 다를 것입니다.

첫 번째 단계는 동일합니다. 앱을 빌드해야 합니다 .

mvn clean install

이것은 pom.xml정의된 frontend-maven-plugin 을 트리거하여 Angular 코드를 빌드하고 UI 아티팩트를 target/classes/static 폴더에 복사합니다. 이 프로세스는 src/main/resources 디렉토리 에 있는 다른 모든 것을 덮어씁니다 . 따라서 복사 프로세스에서 application.yml 과 같은 이 폴더의 모든 필수 리소스를 확인하고 포함해야 합니다 .

두 번째 단계에서는 SpringBootApplication 클래스 UiApplication 을 실행해야 합니다 . 클라이언트 앱은 application.yml에 지정된 대로 포트 8089에서 실행됩니다 .

11. 결론

이 OAuth2 사용방법(예제)에서는 Angular 클라이언트 애플리케이션에 새로 고침 토큰을 저장하는 방법, 만료된 액세스 토큰을 새로 고치는 방법 및 이 모든 것을 위해 Zuul 프록시를 활용하는 방법을 배웠습니다.

이 예제의 전체 구현은 GitHub 에서 찾을 수 있습니다 .

Generic footer banner