【作業記録】環境変数が子プロセスに引き継がれるか確認してみた件

環境変数が子プロセスに引き継がれるか実際に確認してみました

  • 確認してみたことは次の2つ
  • fork() 前に設定していた環境変数は親、子プロセスで見えるか?
  • fork() 後に親で環境変数を追加すると子プロセスで見えるか?
  • 作ったプログラムはこちら
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int
main(void)
{
    putenv("BEFORE_FORK=HELLO");

    pid_t pid;
    pid = fork();

    if (pid == -1)
    {
        perror("");
        exit(EXIT_FAILURE);
    }
    
    if (pid != 0)
    {
        fprintf(stderr, "(parent:%d) getenv %s\n", getpid(), getenv("BEFORE_FORK"));
        fprintf(stderr, "(parent:%d) putenv %s\n", getpid(), "PARENT");
        putenv("PARENT=HELLO_FROM_PARENT");
        wait(NULL);
        exit(EXIT_SUCCESS);
    }
    else
    {
        sleep(10);
        fprintf(stderr, "(child:%d) getenv %s\n", getpid(), getenv("BEFORE_FORK"));
        fprintf(stderr, "(child:%d) getenv %s\n", getpid(), getenv("PARENT"));
        exit(EXIT_SUCCESS);
    }
    
}

動作結果はこちら

$ ./fork_env 
(parent:4552) getenv HELLO
(parent:4552) putenv PARENT
(child:4553) getenv HELLO
(child:4553) getenv (null)

結論として

  • fork() 前にセットしておいた環境変数は親子プロセスで見える
  • fork() 後に親プロセスがセットした環境変数は、子プロセスからは見えない
  • 親プロセス、子プロセスで、事前に渡したい情報があるなら環境変数が使える
  • 事前に渡したい情報がわからないのであればパイプなどを使えばいい?

【作業記録】シグナル

  • シグナルについて調査してみた件
  • プロセスをシェルから起動し、端末から Ctrl - c でSIGINTを送る
  • プロセスは2つにforkする
  • プロセスは親子関係を持つが、シグナルはどうなるのか?

  • 書いてみたプログラム

#include <stdlib.h>
#include <stdio.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

typedef void (*sighandler_t)(int);

sighandler_t trap_signal(int sig, sighandler_t handler);
void sighandler(int sig);

int main(void)
{
    pid_t pid;
    pid = fork();

    if (pid != 0)
    {
        // 親プロセス
        fprintf(stderr, "(parent:%d) waiting for signal ...\n", getpid());
        trap_signal(SIGINT, sighandler);

        pause();

        fprintf(stderr, "(parent:%d) waiting for exit chiled ...\n", getpid());
        wait(NULL);

        fprintf(stderr, "(parent:%d) exit process\n", getpid());
        exit(EXIT_SUCCESS);
    }
    else
    {
        // 子プロセス
        fprintf(stderr, "(chiled:%d) waiting for signal ...\n", getpid());
        trap_signal(SIGINT, sighandler);

        pause();

        fprintf(stderr, "(chiled:%d) exit process\n", getpid());
        exit(EXIT_SUCCESS);
    }
}

sighandler_t
trap_signal(int sig, sighandler_t handler)
{
    struct sigaction act, old;

    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags = SA_RESTART;
    if (sigaction(sig, &act, &old) < 0)
        return NULL;

    return old.sa_handler;
}

void sighandler(int sig)
{
    fprintf(stderr, "(%d) handle signal : %d\n", getpid(), sig);
}
  • 2回動かしてみた結果
$ ./sigaction_propa 
(parent:5503) waiting for signal ...
(chiled:5504) waiting for signal ...
^C(5504) handle signal : 2
(chiled:5504) exit process
(5503) handle signal : 2
(parent:5503) waiting for exit chiled ...
(parent:5503) exit process
$ ./sigaction_propa 
(parent:5621) waiting for signal ...
(chiled:5622) waiting for signal ...
^C(5621) handle signal : 2
(parent:5621) waiting for exit chiled ...
(5622) handle signal : 2
(chiled:5622) exit process
(parent:5621) exit process
  • 伝搬というか、どちらにもシグナルが送られている感じ
  • 送られる順番は決まっていない
  • そもそもシグナルが非同期処理なので、順不同なのは当たり前かも?

【作業記録】mkdir -p の模倣プログラムを作ってみた

mkdir -p の機能を模倣するプログラムをC言語で書いたので、その作業記録です。

作ったものの全量です。

#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        fprintf(stderr, "Usage: mkdir <PATH>");
        exit(EXIT_FAILURE);
    }

    unsigned int current = 0;
    char buf[256];

    for (size_t i = 0;; i++)
    {
        if (argv[1][i] == '\0')
        {
            printf("[create last %s]\n", argv[1]);

            if (mkdir(argv[1], 0775) < 0)
            {
                perror(argv[0]);
                exit(EXIT_FAILURE);
            }
            
            break;
        }

        printf("[now %c]\n", argv[1][i]);

        if (argv[1][i] == '/')
        {
            buf[current] = '\0';

            printf("[create %s]\n", buf);

            if (mkdir(buf, 0775) < 0)
            {
                perror(argv[0]);
                exit(EXIT_FAILURE);
            }

            buf[current] = '/';
        } else {
            buf[current] = argv[1][i];
        }

        current++;
    }

    exit(EXIT_SUCCESS);
}
  • mkdir の練習として「普通のLINUXプログラミング第2版」の問題としてあったのでやりました
  • 上記のプログラムは ./mkdir dirA/dirB/dirC のように使います
  • mkdir は次の2つの引数を取ります
  • 上記のプログラムでは入力された文字を / ごとに判定してディレクトリを作るようにしました
    • \0ディレクトリパスに挿入している箇所がありますが、 \0 は終端文字として認識されるものです

Spring Security で OAuth2 のリソースサーバーへリクエストを送信してみる

Spring Security OAuth2 を使ってクライアントからリソースサーバーにアクセスを行ってみたいと思います。

クライアントがリソースサーバーに対して保護対象リソースを要求するにはアクセストークンを何らかの形で渡す必要があります。おそらくですが、アクセストークンをJWTで表している場合、AuthorizationヘッダーにBearerスキーマで送信することがほとんどなんじゃないかと思ってます。今回はJWT+Bearerスキーマでリソースサーバーに対してアクセスを行ってみます。

参考にしたのはこちらの本家リファレンスです。

https://docs.spring.io/spring-security/site/docs/current/reference/html5/#oauth2Client-webclient-servlet

https://spring.pleiades.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux-client

また、プロジェクトは前回のものを使っているのでセットアップなどは割愛します。ソースコードはこちらにあります。

https://github.com/BooookStore/spring-examples/tree/master/spring-security-oauth2-client

WebClient を Bean 登録する

アクセスをするにはいくつか方法があるかと思うのですが、公式リファレンスに書いてあるとおりに実装をします。

まず、リクエストを作成するために必要になる WebClient をBean登録します。 WebClient は Spring WebFlux の一部なので、こちらを依存関係に追加して上げる必要があります。

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
    implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
    implementation("org.springframework.boot:spring-boot-starter-webflux") // 追加
    implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity5")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation(platform("org.junit:junit-bom:5.7.0"))
    testImplementation("org.junit.jupiter:junit-jupiter")
}

tasks.test {
    useJUnitPlatform()
}

WebClient を使うのは初めてなのですが、Spring MVC で言うところの Rest Template 的な役割みたいです。 WebClient を使うことでアクセストークンを自動的にリクエストヘッダに入れ込んでくれます。 WebClient を使う簡単なExampleは以下の公式チュートリアルに載ってます。後でやろう。

https://spring.pleiades.io/guides/gs/reactive-rest-service/

WebClient のBean 登録はこんな感じになりました。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager(
            ClientRegistrationRepository clientRegistrationRepository,
            OAuth2AuthorizedClientRepository auth2AuthorizedClientRepository) {
        return new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, auth2AuthorizedClientRepository);
    }

    @Bean
    WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
                new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        return WebClient.builder()
                .apply(oauth2Client.oauth2Configuration())
                .build();
    }

    // 他省略

}

ServletOAuth2AuthorizedClientExchangeFilterFunction をセットしています。このクラスをインスタンス化するときに OAuth2AuthorizedClientManager が必要になりますが、それは別でBean登録したものを持ってきて使っています(デフォルトでBeanに登録されているものかと思ったんですが、いなかったです。居てもいいきがするんだけどな)。OAuth2AuthorizedClientManager はクライアントの認可・再認可を行うクラスなので、おそらくですが WebClient がリフレッシュトークンを使ってクライアントを再認可するときに使われるものなのかと。

WebClient#apply メソッドも何しているか気になったんですが、横道にそれすぎる気がしたのでこちらもまた別の機会に調べようと思います。

ところで OAuth2AuthorizedClientManager の具象クラスは DefaultOAuth2AuthorizedClientManagerAuthorizedClientServiceOAuth2AuthorizedClientManager があるようです。Javadocを見たらこんな違いがありました。今回は HttpServletRequest コンテキストなので Default の方を使うのが良さそうですね。

  • DefaultOAuth2AuthorizedClientManager ... HttpServletRequest コンテキスト内で使用する
  • AuthorizedClientServiceOAuth2AuthorizedClientManager ... HttpServletRequest コンテキスト外で使用する

リソースサーバーに対するリクエストを作成する

Bean登録した WebClient を使ってリクエストを作成します。コントローラーのメソッドはこうなりました。

@Controller
@RequestMapping("/home")
public class HomeController {

    private final Logger logger = LoggerFactory.getLogger(HomeController.class.getName());

    private final OAuth2AuthorizedClientService authorizedClientService;

    private final WebClient webClient;

    public HomeController(OAuth2AuthorizedClientService authorizedClientService, WebClient webClient) {
        this.authorizedClientService = authorizedClientService;
        this.webClient = webClient;
    }

    // 略

    @GetMapping("request")
    public String requestToResourceServer(@RegisteredOAuth2AuthorizedClient("spring-oauth2-client-demo") OAuth2AuthorizedClient authorizedClient) {
        String body = webClient
                .get()
                .uri("http://localhost:8082/getDocument")
                .attributes(oauth2AuthorizedClient(authorizedClient))
                .retrieve()
                .bodyToMono(String.class)
                .block();

        logger.info("retrieve from resource server [{}]", body);

        return "home";
    }

}

今回は検証目的で作成したのでURIが直書きになってます。本来であれば外部から与えるべきでしょうね。WebClient#attributes でヘッダーにJWTを追加するためにリクエストに AuthorizedClient を属性として追加しています。

ここでセットされた属性はBean登録時に設定した ServletOAuth2AuthorizedClientExchangeFilterFunction で処理され、そこでBearerスキーマとしてヘッダーに追加されるようですね。

Spring Reference | webflux-client-attributes

GitHub | source of ServletOAuth2AuthorizedClientExchangeFilterFunction#filter

GitHub | source of ServletOAuth2AuthorizedClientExchangeFilterFunction#bearer

ちなみに、AuthorizedClient をメソッドの引数としてBeanを取得していますが、認可サーバーから認可を受けていなければ自動的に認可フローが開始され、認可画面へリダイレクトされます。この動作はリソースサーバーへリクエストを送るかどうかに関わらずメソッドの引数に AuthorizedClient がある場合の動作になるようです。便利ですね。

動作検証

動作の確認をしてみます。検証なのでクライアントがリソースサーバーへどのようなリクエストを送信しているかをモックサーバーでキャプチャしてみました。実際にキャプチャした結果がこちら。

  {
    "method" : "GET",
    "path" : "/getDocument",
    "headers" : {
      "accept-encoding" : [ "gzip" ],
      "user-agent" : [ "ReactorNetty/1.0.4" ],
      "host" : [ "localhost:8082" ],
      "accept" : [ "*/*" ],
      "Authorization" : [ "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJHNmdnTHhVcFFOWTdsOGwxaHRaQ1QxbGdlLXJuTy01U1ZTNjRHYTN0eENjIn0.eyJleHAiOjE2MjAwMjg0MTUsImlhdCI6MTYyMDAyODExNSwiYXV0aF90aW1lIjoxNjIwMDI4MTE1LCJqdGkiOiI1MjdlYmNlNi03ZDBkLTQ1ZDAtYThjYS03YWUyOWYxNGZiN2EiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODEvYXV0aC9yZWFsbXMvc3ByaW5nLW9hdXRoMiIsInN1YiI6ImIyOTE1NDRlLTM5YzYtNGE2MS04YTAwLTc0MGIwZDllY2QwMyIsInR5cCI6IkJlYXJlciIsImF6cCI6InNwcmluZy1vYXV0aDItY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6ImNiZjZhYjhjLThiY2QtNGFiOS04N2EzLTYzMjk3NmUyYWU2YSIsImFjciI6IjEiLCJzY29wZSI6InNwcmluZy1vYXV0aDItY2xpZW50In0.WvVroOdGjrJQsV-zPQLR5tjEmrIPMXfLilHL1agk3QPseFjQioDLgWocQIL28b_c5WptXMT8oJICKgyAZOhs2rjMDIQCFWqv2A-LbdX_scgS_GJr_QjCdWgRrztPF1p3O7RMbXnRC93H1xQBDuCBoySAd7mjJNSe2tdwQL2AiWzNwAnru67BfJmE7dpCcPT92LlxOGoVRnTOXwEE_C1IsZn_70caNL1g_XJ9IrxFTpsIq4EXcDXEpwSPWuPTAc0-HyDowI8dQXLT8KNsUYpTqrIJF2FPNMGlLaEOg-CXQTM3M_t7JV-V-oNuixgIZ7sXXjGdXPvWBvYaAwz3lN9lcA" ],
      "content-length" : [ "0" ]
    },
    "keepAlive" : true,
    "secure" : false
  }

MockServerを使っているので、リクエストがJSON形式で表示されています。AuthorizationヘッダーにBearerスキーマでJWTが送られてきているのが確認できますね。

Spring Security OAuth2 Client でKeycloakからアクセストークンを取得してみる

前回はJavaScriptを用いてSPAでアクセストークンを取得してみましたが、今回はSpring Securityを用いてサーバーによるアクセストークン取得をやってみたいと思います。

といいましても、Spring Securityが細かい実装の部分をサポートしてくれているので、やることはそんなに多くないです。が、中身の理解が大切になってくるかなと思いますのでまずはそちらから。

Spring Security OAuth2 Client の中身を理解したい

参考にしたのは公式リファレンスのこのあたりです。

https://docs.spring.io/spring-security/site/docs/current/reference/html5/#oauth2Client-core-interface-class

https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#boot-features-security-oauth2-client

ざーっくりと主要なクラス、インターフェイスをまとめてみるとこんな感じかなと。それぞれの関係性は追えていない部分もあり、イメージで引いているものなので参考程度に。

f:id:bau1537:20210228193536j:plain

この中で特に中心的な役割になるのが次のクラス、インターフェイスになるかと思います。

  • ClientRegistration:クライアントの登録情報。クライアントIDやクライアントシークレットの値、また認可URIトークURIの値を持つ。
  • OAuth2AuthorizedClient:リソースオーナーから認可を受けたことを示す。 ClientRegistration と アクセストークン、リフレッシュトークンを結びつける。
  • OAuth2AuthorizedClientProvider:特定の認可フローの実装。結果として OAuth2AuthorizedClient を返す。

ClientRegistrationOAuth2AuthorizedClientProvider はSpring Bootのapplicationファイルから設定する感じになります。 OAuth2AuthorizedClient はアクセストークンを受け取った後に生まれるもので、リソースサーバーにアクセスする処理においてアクセストークンを取り出すときなどに使用するイメージかなと思っています。

とりあえず動かしてどんな感じなのかを調べていきたいと思います。

Spring Security でクライアントを作成する

Spring Boot を使って行きます。 build.gradle.kts はこちらなんですが、マルチプロジェクトの構成にしているのフルで依存関係を知りたければ以下のリポジトリを見てもらえればと思います。OAuth2 クライアントにするには spring-boot-starter-oauth2-client が必要になります。

github.com

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
    implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
    implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity5")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation(platform("org.junit:junit-bom:5.7.0"))
    testImplementation("org.junit.jupiter:junit-jupiter")
}

tasks.test {
    useJUnitPlatform()
}

Config クラスはこのようになりました。ユーザー管理を行うよう設定します。というのも、アクセストークンやリフレッシュトークンはユーザーごとにセッションに保存されるみたいなので、設定が必要になるようです。

configure メソッドで http.oauth2Client() によりクライアントとして設定します。

package com.example.spring.security.oauth2.client.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager userDetailsManager = new InMemoryUserDetailsManager();
        userDetailsManager.createUser(User.withUsername("bob").password("12345").roles("user").build());
        return userDetailsManager;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin();
        http.oauth2Client();

        http.authorizeRequests()
                .anyRequest().authenticated();
    }

}

applicationファイルはこうなりました。クライアントシークレットが載ってますが、これは学習用で作成した値なので載せてます。本来この値は公開してはいけないクレデンシャル値なので、本番環境とかの値とかであれば絶対公開しないようにしましょう。

spring:
  security:
    oauth2:
      client:
        registration:
          spring-oauth2-client-demo:
            client-id: spring-oauth2-client
            client-secret: 2f7b1965-44d3-46df-9e5b-1655af84d0f2
            provider: "local-docker-keycloak"
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/home"
            scope: spring-oauth2-client
        provider:
          local-docker-keycloak:
            authorization-uri: http://localhost:8081/auth/realms/spring-oauth2/protocol/openid-connect/auth
            token-uri: http://localhost:8081/auth/realms/spring-oauth2/protocol/openid-connect/token

値は大きく2つのカテゴリに分けられていて、 registration で設定している値が ClientRegistration クラスに対応しています。provider で設定している値が OAuth2AuthorizedClientProvider クラスに対応しています。

ここで設定している spring-oauth2-client-demo という値が registration id と言われるものですが、この値は認可フローを開始するURLの一部となっていて、この場合だと /oauth2/authorization/spring-oauth2-client-demo になります。URLの末尾が registration id になるというわけです。このURLにアクセスすると設定している値に基づいて認可フローが開始されます。 なので、リンクをこのURLに指定して認可フローを開始させるといった使い方が予想できますね。

spring-oauth2-client-demo.provider の箇所では使用する provider を指定しています。イメージとしては ClientRegistration クラスごとに、使用する認可サーバーの情報を OAuth2AuthorizedClientProvider クラスとして指定するような感じでしょうか。今回は一つの registrationprovider を設定していますが、複数設定することもできるというわけです。このあたりは柔軟な構造になっている印象を受けますね。

一つコントローラーを作成しエンドポイントを作成します。

package com.example.spring.security.oauth2.client.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/home")
public class HomeController {

    private final Logger logger = LoggerFactory.getLogger(HomeController.class.getName());

    private final OAuth2AuthorizedClientService authorizedClientService;

    public HomeController(OAuth2AuthorizedClientService authorizedClientService) {
        this.authorizedClientService = authorizedClientService;
    }

    @GetMapping
    public String home(Authentication authentication, Model model) {
        OAuth2AuthorizedClient authorizedClient = authorizedClientService.loadAuthorizedClient(
                "spring-oauth2-client-demo",
                authentication.getName()
        );
        model.addAttribute("authorized", authorizedClient != null);

        if (authorizedClient != null) {
            logger.info("access token value [{}]", authorizedClient.getAccessToken().getTokenValue());
        }

        return "home";
    }

}

authorizedClientService に対する呼び出しを見てもらえればわかりますが、 Authentication#getName() の値から OAuth2AuthorizedClient を取得できます。そしてこのオブジェクトを経由して OAuth2AccessToken オブジェクトを取得できます。

このエンドポイントで返しているHTMLはこうなっています。認可しているかどうかで表示内容を変更するようにしています。認可していない場合、認可フローを開始するリンクをアクティブにします。

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Home</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"
          integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
  </head>
  <body>
    <div class="container">
      <div class="row">
        <div class="col">
          <h1>OAuth2 Client Demo</h1>
        </div>
      </div>
      <div class="row">
        <div class="col">
          <p th:if="${authorized}">本アプリを認可しました。</p>
        </div>
      </div>
      <div class="row">
        <div class="col">
          <a class="btn btn-primary" th:classappend="${authorized ? 'disabled' : ''}" th:href="@{/oauth2/authorization/spring-oauth2-client-demo}" href="#">Keycloakにより本アプリを認可する</a>
        </div>
      </div>
    </div>
  </body>
</html>

実装はこんな感じになりました。

動作としてはこうなります。

まず /home にアクセスします。

f:id:bau1537:20210303210130p:plain

認可フローを開始するとKeycloakにリダイレクトされログインを求められます。

f:id:bau1537:20210303210203p:plain

ログインに成功すると /home に戻されます。この実装だとコンソールログにアクセストークンを出力してますがあくまでも検証ですので本番ではNGです。

f:id:bau1537:20210303210230p:plain

2021-03-03 21:02:17.954  INFO 10823 --- [nio-8080-exec-7] c.e.s.s.o.c.controller.HomeController    : access token value [eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJEeDVVV1p....

OAuth 2.0 のJavaScriptクライアントを作ってみる

OAuth 2.0 とか OpenID Connect の全体のフローを作ってみたいと思ったので、とりあえずOAuth 2.0で動くJavaScriptクライアントをSingle Page Applicationとして作ってみたいと思います。

OAuth 2.0 を利用する上で推奨されるセキュリティ上の対策がいくつかあると思うんですが、まずは単純な認可コードによる付与方式のクライアントを作っていこうかなと。なので、プロダクションとして使えるレベルのものではないです。後日、今回作ったものをベースに少しずつ機能なりを足していければいいかなといった感じです。OAuth 2.0 クライアントもどきだと思ってください。

認可コードフロー

OAuth 2.0 の認可コードフローを図にまとめてみました。よく忘れがちなんですよね...。こうやって図として自分で書いてみると、ちょっと理解が進む気がします。

f:id:bau1537:20210223124014j:plain

使用させていただいたアイコン Laptop Icon by Bambang Dewanto on Iconscout / Server Icon by Bambang Dewanto / Badge Icon by Dalpat Prajapati on Iconscout

今回はこのフローの中でフロントチャネルコミュニケーションと、バックチャンネルコミュニケーションの部分を作ってみようかなと思います。最終的に認可サーバーからアクセストークンを取得できればOKということになるわけですね。

  • フロントチャネルコミュニケーション:リソースオーナーのブラウザを通して認可サーバーとやり取りすること
  • バックチャンネルコミュニケーション:認可サーバーと直接やり取りすること

今回使う技術要素はこちら。

  • クライアントアプリケーション:Vue.js (version 2.6.11、下のpackage.jsonで詳しく書いてます)
  • 認可サーバー:Keycloak (docker image version 12.0.3)

Vueアプリケーションの作成

JavaScriptフレームワーク、Vueを使って作っていきます。OAuth対応のライブラリもいくつかあるのですが、今回は学習が目的なので、使いません。 package.json はこんな感じ。axios はバックチャンネルコミュニケーショのために使います。

Vue CLIvue create コマンドで作成したアプリケーションなので特に変わった設定などはありません。

{
  "name": "vue-oauth-client",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "axios": "^0.21.1",
    "core-js": "^3.6.5",
    "vue": "^2.6.11",
    "vuex": "^3.4.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-eslint": "~4.5.0",
    "@vue/cli-plugin-vuex": "~4.5.0",
    "@vue/cli-service": "~4.5.0",
    "babel-eslint": "^10.1.0",
    "eslint": "^6.7.2",
    "eslint-plugin-vue": "^6.2.2",
    "vue-template-compiler": "^2.6.11"
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "babel-eslint"
    },
    "rules": {}
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

フロントチャネルコミュニケーションの実装

フロントチャネルコミュニケーションで認可サーバーから認可コードを受け取るまでを作っていきます。まずは認可サーバーの認可エンドポイントへリソースオーナーをリダイレクトさせます。その際にURLのリクエストパラメーターに必要な情報をセットしておきます。

OAuth の各アクターがコミュニケーションする際の内容はこちらの記事にわかりやすく載っていました。OAuth でわからなければこの記事の著者で検索すれば大抵のことはわかりやすく書いてくださっているので、とても助かります。

qiita.com

認可サーバーへ渡す情報はRFC6749のこちらのセクションにあります。

https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-req

まとめるとこんな感じ。

  • response_type:レスポンスタイプ。認可コードによる付与方式の場合は code で固定。
  • scope:要求するアクセストークンが保持するスコープ。
  • clientId:クライアントID。
  • redirect_uri:リダイレクトURL。

リダイレクトURL

この中で少し気になったのが redirect_uri です。今回作成しているのはブラウザベースで動くアプリケーションであり、この種類のアプリケーションはクライアントシークレットを持つことができません。このような種類のアプリケーションはOAuthでは公開クライアント(Publicクライアント)として定義されています。ブラウザベースで動くアプリケーションの他にもスマホで動くアプリケーションや、PCでネイティブ動作するアプリケーションも公開クライアントです。

f:id:bau1537:20210223162526j:plain

公開クライアントはRFC6749のOAuth 2.0仕様において、正しいリダイレクトURLを事前に設定しておくことが求められています。事前設定されたURLと異なるURLをリクエストパラメーターにセットしてリダイレクトするとエラーが返却され、認可コードを受け取ることができない仕様になっています。

https://openid-foundation-japan.github.io/rfc6749.ja.html#redirect-uri

認可サーバーは, 以下のようなクライアントに対してリダイレクトエンドポイントの事前登録を要求すること (MUST):

パブリッククライアント. インプリシットグラントタイプを利用するコンフィデンシャルクライアント.

ちなみに不正なリダイレクトURLを渡すと、Keycloakはこんなエラー画面を表示します。

f:id:bau1537:20210223170840p:plain

なんでこんな仕様になっているかというと redirect_uri を認可サーバーへ渡す時に自由に設定できてしまったらクライアントをなりすましてアクセストークンを取得できてしまうので、ここは制限をかける必要があるということですね。

Keycloakのドキュメントの public または Valid Redirect URIs のセクションにこれらの詳細が書いてあります。

https://keycloak-documentation.openstandia.jp/3.4/ja_JP/server_admin/index.html#oidc%E3%82%AF%E3%83%A9%E3%82%A4%E3%82%A2%E3%83%B3%E3%83%88

また、こちらにもその記載がるので一読しておくといいかと思います。

https://keycloak-documentation.openstandia.jp/3.4/ja_JP/server_admin/index.html#_oidc-auth-flows

このあたりの話はブラウザベースで動くアプリケーションがOAuthを利用する際の設定事項を制定しているRFCにもちらっと載っているので、後でこのあたりも読んでおきたいですね。ちなみにこちらのRFCは現段階ではドラフト版なので内容が変わる可能性があることに注意です。

https://tools.ietf.org/html/draft-ietf-oauth-browser-based-apps-07

OAuth 2.0 authorization servers:

o MUST Require exact matching of registered redirect URIs

その他、公開クライアントはクライアントシークレットを持つことができない点から、PKCEなど、安全にOAuthのフローを利用する方法などが提案されているようです。(今回はやりませんが)

Vueアプリケーションから認可サーバーへリダイレクトさせる

では、アプリケーションコードを書いていきます。やることを整理すると、リソースオーナーをリダイレクトさせ(1)、Keycloakで認証認可を終えて帰ってきた時に認可コードを受け取ります(2)。

f:id:bau1537:20210223164023j:plain

認可エンドポイントはロジックの中に埋め込んでしまうと保守上よろしくないので、configファイル的な役割のファイルに定義します。

// authorizationServer.js
export default {
    authorizationEndpoint: 'http://localhost:8081/auth/realms/vue-oauth-client-realm/protocol/openid-connect/auth',
}

ボタンを持つVueコンポーネントを定義し、ボタンがクリックされると認可サーバーへリダイレクトするようにします。後で設定しますが、Vuexを使用してアクセストークンを保持しているか、いないかを $store.state.isLogin で管理しています。(ちょっと名前が変かもです。OIDCにつられてログインと書いてます)

<template>
  <div>
    <button @click="redirectToKeycloak" :disabled="$store.state.isLogin">Keycloakによるアクセストークンの取得</button>
  </div>
</template>

<script>
import authorizationServer from "@/config/authorizationServer";

const response_type = 'code';
const scope = 'vue-oauth-client';
const clientId = 'vue-oauth-client';
const redirect_uri = 'http://localhost:8080';

export default {
  name: "Login",
  methods: {
    redirectToKeycloak() {
      console.log("redirect to keycloak");
      location.href = `${authorizationServer.authorizationEndpoint}`
          + `?response_type=${response_type}`
          + `&scope=${scope}`
          + `&client_id=${clientId}`
          + `&redirect_uri=${redirect_uri}`;
    }
  }
}
</script>

アプリケーションとしてはこれで動きますが、Keycloakの設定が事前にされていないとエラーが帰ってきます。Keycloakの設定は大まかに以下の通りに設定しました。

  • vue-oauth-client-realm realmを作成。
  • vue-oauth-client clientを作成。
  • vue-oauth-client clientのリダイレクトURLをVueアプリケーションが起動しているhttp://localhost:8080に設定。
  • vue-oauth-client scopeを作成し、上記のclientのclient scopeに関連付け。
  • bob userを作成し、パスワードを password に設定。

他の設定はデフォルトのままです。

では動かしてみましょう。

npm serve によりアプリを起動させるとlocalhost:8080で開くことができます。

f:id:bau1537:20210223171630p:plain

先ほど作成したボタンをクリックすると、javascriptによりリダイレクトされ、Keycloakの認可エンドポイントへ遷移します。

f:id:bau1537:20210223171731p:plain

bob で Sign In すると、元のアプリケーションにリダイレクトされ、その際にURLに認可コードがセットされています。

f:id:bau1537:20210223171854p:plain

認可コードを受け取ったら、バックチャンネルコミュニケーションでアクセストークンを要求します。

バックチャンネルコミュニケーション

アクセストークンを要求するにはバックチャンネルコミュニケーションでクライアントと認可サーバーが直接やり取りします。

f:id:bau1537:20210224180532j:plain

Vueのコードはこちらになります。

トークンエンドポイントを設定ファイルに追記します。

// authorizationServer.js
export default {
    authorizationEndpoint: 'http://localhost:8081/auth/realms/vue-oauth-client-realm/protocol/openid-connect/auth',
    tokenEndpoint: '/auth/realms/vue-oauth-client-realm/protocol/openid-connect/token',
}

ここで http://localhost:8080 がついていませんが、これはブラウザの同一オリジンポリシーによりVueのアプリから認可サーバーへリクエストが飛ばせないので、Vueの開発サーバーをプロキシする関係上このようにしています。Vueの開発サーバーはこのような設定にしています。

// vue.config.js
module.exports = {
    devServer: {
        proxy: 'http://localhost:8081'
    }
}

肝心のリクエストを行う処理はこんな感じ。認可サーバーからリダイレクトされ、Vueが初期化されるタイミングでURLを検査し、認可コードが有る場合は fetchAccessToken 関数でトークンエンドポイントに向けてaxiosを使ってリクエストを投げてます。

<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <Login/>
  </div>
</template>

<script>
import Login from "@/components/Login";
import axios from 'axios';
import store from '@/store'
import authorizationServer from "@/config/authorizationServer";

function fetchAccessToken(searchUrl) {
  console.log('redirected from keycloak');
  searchUrl.split('&').forEach((e) => {
    const redirectedParameter = e.split('=');
    console.log(`key: ${redirectedParameter[0]}, value: ${redirectedParameter[1]}`);

    if (redirectedParameter[0] === 'code') {
      const code = redirectedParameter[1];

      console.log('get access token to keycloak');
      const params = new URLSearchParams();
      params.append('grant_type', 'authorization_code');
      params.append('code', code);
      params.append('redirect_uri', 'http://localhost:8080')
      params.append('client_id', 'vue-oauth-client');

      axios.post(authorizationServer.tokenEndpoint, params)
          .then(response => {
            console.log(response);

            const accessToken = response.data.access_token;
            console.log(`access token: ${accessToken}`);

            store.commit('loginSuccess', {accessToken});
          })
          .catch(err => {
            console.log(err);
          });
    }
  });
}

export default {
  name: 'App',
  components: {
    Login
  },
  beforeCreate() {
    const searchUrl = location.search.substring(1);

    if (searchUrl !== '') {
      fetchAccessToken(searchUrl);
    }
  }
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

ここで、アクセストークンをコンソールログに出力していますがこれはアクセストークンが外に漏れる可能性が高くなり、大変危険なのでプロダクションでは絶対にやってはだめです。今回は学習目的で出力しています。

実際にアプリを動かしてみるとこんな感じになります。アクセストークンが取得できているのがわかりますね。

f:id:bau1537:20210224182410p:plain

とりあえず、今回はここまで。

Spring Security の認証をテストしてみる

前回は認可をテストしてみたので、認可についてもテストしてみようかと思います。

認可については認証から切り離してテストすることができましたが、認証は認可と切り離さずテストする感じですね。というのも、このやり方は AuthenticationFilter ごと実行させることになるので認証認可に関係する部分はすべて動作します。

ただ、認証認可に関する処理を一つのテストで網羅してしまうと、一つのテストに複数のテスト対象が入り込んでしまい複雑になってしまいます。なので、認証のテストではログインページへのアクセスなど、認可処理が入り込まないエンドポイントなどを使用して、テストをシンプルに保つのがいいのではないかと思います。

では、実装していきましょう。

コードは前回の記事のものをベースに拡張していきます。

baubaubau.hatenablog.com

Configuration クラスにパスワードエンコーダーをBean登録します。これがないと AuthenticationProvider がパスワードの突合ができず、テストが失敗してしまいます。

package com.example.spring.security.test.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public UserDetailsService userDetailsService() {
        var userDetailsManager = new InMemoryUserDetailsManager();
        userDetailsManager.createUser(
                User.withUsername("alice")
                        .password("12345")
                        .roles("admin")
                        .build()
        );
        userDetailsManager.createUser(
                User.withUsername("bob")
                        .password("12345")
                        .roles("staff")
                        .build()
        );
        return userDetailsManager;
    }

    @SuppressWarnings("deprecation")
    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin();
        http.authorizeRequests()
                .mvcMatchers("/admin/**").hasRole("admin")
                .mvcMatchers("/users/**").hasAnyRole("admin", "staff")
                .anyRequest().authenticated();
    }

}

で、テストコードがこちら。

package com.example.spring.security.test;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;

@SpringBootTest
@AutoConfigureMockMvc
public class AuthenticationTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    public void formLoginWithInvalidCredential() throws Exception {
        mockMvc.perform(formLogin().user("bob").password("11111"))
                .andExpect(unauthenticated());
    }

    @Test
    public void formLoginWithValidCredential() throws Exception {
        mockMvc.perform(formLogin().user("bob").password("12345"))
                .andExpect(authenticated().withRoles("staff"));
    }

}

formLogin() という RequestBuilder の実装を使うことでフォームログインを行います。この実装はSpringSecurityTestの機能の一つで、フォームログインを簡単にテストできるようにしてくれています。メソッドチェーンでユーザー名とパスワードを指定する感じですね。

https://docs.spring.io/spring-security/site/docs/5.4.2/reference/html5/#securitymockmvcrequestbuilders

authenticated() unauthenticated() でログインが成功/失敗したことを検証できます。続けて withRoles などでログイン後の認証情報も検証できるようです。

https://docs.spring.io/spring-security/site/docs/5.4.2/reference/html5/#securitymockmvcresultmatchers

ソースはこちらにアップしてあります。

github.com