1. 概要

このチュートリアルでは、まとめ始めたOAuth2認証コードフローの調査を続けます。 前回の記事 Angularアプリで更新トークンを処理する方法に焦点を当てます。 また、Zuulプロキシを利用します。

Springセキュリティ5でOAuthスタックを使用します。SpringセキュリティOAuthレガシースタックを使用する場合は、この前の記事をご覧ください:OAuth2でSpring ] REST API – AngularJS(レガシーOAuthスタック)で更新トークンを処理します

2. アクセストークンの有効期限

まず、クライアントが2つのステップで認証コード付与タイプを使用してアクセストークンを取得していたことを思い出してください。 最初のステップでは、認証コードを取得します。 そして2番目のステップでは、実際にアクセストークンを取得します。

アクセストークンは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 でクライアントシークレットを送信していますが、これは実際にはこれを処理するための安全な方法ではありません。 これを回避する方法を見てみましょう。

3. プロキシ

したがって、フロントエンドアプリケーションでZuulプロキシを実行し、基本的にフロントエンドクライアントと承認サーバーの間に配置します。 すべての機密情報はこのレイヤーで処理されます。

これで、フロントエンドクライアントがブートアプリケーションとしてホストされるようになり、SpringCloudZuulスターターを使用して組み込みの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に保存します

ここで興味深いのは、トラフィックを承認サーバーにプロキシするだけで、他には何もプロキシしないことです。 クライアントが新しいトークンを取得しているときにプロキシが入る必要があるだけです。

次に、これらすべてを1つずつ見ていきましょう。

4. ZuulPreFilterを使用してコードを取得する

プロキシの最初の使用は簡単です。認証コードを取得するためのリクエストを設定します。

@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_id、redirect_uriのクエリパラメーターを追加します –認証サーバーがログインページに移動してコードを送り返すために必要なすべてのもの。

shouldFilter()メソッドにも注意してください。 上記の3つのURIを使用してリクエストをフィルタリングするだけで、他のリクエストはrunメソッドを通過しません。

5. コードをCookieに入れる 使用する ズールポストフィルター

ここで計画しているのは、コードをCookieとして保存し、認証サーバーに送信してアクセストークンを取得できるようにすることです。 コードは、認証サーバーがログイン後にリダイレクトするリクエストURLのクエリパラメータとして存在します。

このコードを抽出してCookieに設定するZuulポストフィルターを設定します。これは通常の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-SiteCookieヘッダーを追加します。

そのために、構成クラスを作成します。

@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アプリケーションがトークンリクエストをトリガーしようとすると、 / auth / token にリクエストが送信されるため、ブラウザは次のようになります。もちろん、そのクッキーを送ってください。

したがって、プロキシの pre フィルタに、がCookieからコードを抽出し、他のフォームパラメータと一緒に送信してトークンを取得するという別の条件があります。

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. 更新トークンを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ポストフィルターに条件を追加して、応答を読み取り、ルート auth /tokenおよびauth/refreshの更新トークンを抽出しました。 承認サーバーは、アクセストークンと更新トークンを取得するときに基本的に同じペイロードを送信するため、2つに対してまったく同じことを行っています。

次に、JSON応答から refresh_token を削除して、Cookieの外部でフロントエンドにアクセスできないようにしました。

ここで注意すべきもう1つのポイントは、Cookieの最大有効期間を30日に設定したことです。これは、トークンの有効期限と一致するためです。

8. Cookieから更新トークンを取得して使用する

Cookieに更新トークンが含まれているので、フロントエンドAngularアプリケーションがトークン更新をトリガーしようとすると、 / auth /refreshにリクエストが送信されます。もちろん、ブラウザはそのCookieを送信します。

これで、プロキシのプレフィルターに別の条件が設定され、Cookieから更新トークンが抽出されて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();
        }
    } 
    ...
}

これは、最初にアクセストークンを取得したときに行ったことと似ています。 ただし、フォーム本体が異なることに注意してください。 これで、以前にCookieに保存したトークンとともに、authorization_codeの代わりにgrant_typeのrefresh_tokenを送信します。

応答を取得した後、セクション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アプリケーションとしてホストされるようになったため、実行方法は以前とは少し異なります。

最初のステップは同じです。 アプリを構築する必要があります

mvn clean install

これにより、pom.xmlで定義されたfrontend-maven-pluginがトリガーされ、Angularコードがビルドされ、UIアーティファクトが target / classes /staticにコピーされます。フォルダ。 このプロセスは、 src / main /resourcesディレクトリにある他のすべてのものを上書きします。 したがって、 application.yml など、このフォルダーから必要なリソースを確認して、コピープロセスに含める必要があります。

2番目のステップでは、SpringBootApplicationクラスUiApplicationを実行する必要があります。 application.yml で指定されているように、クライアントアプリはポート8089で稼働します。

11. 結論

このOAuth2チュートリアルでは、更新トークンをAngularクライアントアプリケーションに保存する方法、期限切れのアクセストークンを更新する方法、およびそれらすべてにZuulプロキシを活用する方法を学習しました。

このチュートリアルの完全な実装は、GitHubにあります。