ラベル SpringBoot の投稿を表示しています。 すべての投稿を表示
ラベル SpringBoot の投稿を表示しています。 すべての投稿を表示

2025年8月11日月曜日

Spring Security で API Key を使った認証を行う

Spring Initializr

これ

アプリの作成

Echo エンドポイントの作成

誰でも叩ける /echo と、 API Key が無いとたたけない /api/echo を用意する。

package dev.mikoto2000.springboot.security.apikey.firststep.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * EchoController
 */
@RestController
public class EchoController {

  @GetMapping("/echo")
  public String echo(
      @RequestParam String message) {
    return message;
  }

  @GetMapping("/api/echo")
  public String apiEcho(
      @RequestParam String message) {
    return message;
  }
}

セキュリティ設定

セキュリティフィルターの作成

properties に設定した API Key と等しいときだけ認証が通るフィルターを作成。

他プロジェクトで使いまわせるように clientName と同じロールを設定するようにしている。

package dev.mikoto2000.springboot.security.apikey.firststep.security;

import java.io.IOException;
import java.util.List;

import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public class ApiKeyAuthFilter extends OncePerRequestFilter {

  private static final String API_KEY_HEADER = "x-api-key";

  private final String clientName;
  private final String apiKey;

  public ApiKeyAuthFilter(String clientName, String apiKey) {
    this.clientName = clientName;
    this.apiKey = apiKey;
  }

  @Override
  protected void doFilterInternal(HttpServletRequest request,
      HttpServletResponse response,
      FilterChain chain) throws IOException, ServletException {

    // CORSプリフライトは素通し(必要に応じて)
    if (HttpMethod.OPTIONS.matches(request.getMethod())) {
      chain.doFilter(request, response);
      return;
    }

    // 既に認証済みならスキップ
    Authentication current = SecurityContextHolder.getContext().getAuthentication();
    if (current != null && current.isAuthenticated()) {
      chain.doFilter(request, response);
      return;
    }

    // API Key による認証
    String requestApiKey = request.getHeader(API_KEY_HEADER);
    if (StringUtils.hasText(apiKey) && StringUtils.hasText(requestApiKey) && apiKey.equals(requestApiKey)) {
      // API クライアント用トークン作成
      var token = new UsernamePasswordAuthenticationToken(
          clientName,
          "N/A",
          List.of(new SimpleGrantedAuthority(String.format("ROLE_%s", clientName))));
      token.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

      // SecurityContextHolder に認証済みトークンを設定
      SecurityContextHolder.getContext().setAuthentication(token);
    }

    // 次のフィルタへ
    chain.doFilter(request, response);
  }
}

SecurityConfig の作成

認証エラー時に相手に与える情報は少ない方が良いので空ボディを返すようにしている。

package dev.mikoto2000.springboot.security.apikey.firststep.security;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import jakarta.servlet.http.HttpServletResponse;

/**
 * SecurityConfig
 */
@Configuration
public class SecurityConfig {

  @Value("${security.api.client-role}")
  private String apiClientName;

  @Value("${security.api.key}")
  private String apiKey;

  @Bean
  public SecurityFilterChain apiChain(HttpSecurity http,
      AuthenticationEntryPoint emptyBody401EntryPoint) throws Exception {

    var apiKeyFilter = new ApiKeyAuthFilter(apiClientName, apiKey);

    http
        .securityMatcher("/api/**")
        .csrf(csrf -> csrf.disable())
        .cors(Customizer.withDefaults())
        .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .exceptionHandling(eh -> eh.authenticationEntryPoint(emptyBody401EntryPoint))
        .addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class)
        .authorizeHttpRequests(auth -> auth
            .anyRequest().hasRole("TEST"));

    return http.build();
  }

  /**
   * 401 を本文なしで返す EntryPoint
   */
  @Bean
  public AuthenticationEntryPoint emptyBody401EntryPoint() {
    return (request, response, ex) -> {
      response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
      // ここで Content-Type や Body は書かない(Content-Length: 0)
    };
  }
}

設定ファイル作成

spring.application.name=firststep
security.api.client-role=TEST
security.api.key=0123456789

動作確認

サーバー起動

./mvnw spring-boot:run

リクエスト発行

curl -v localhost:8080/echo?message=aaaaaaaaa
=> aaaaaaaaa

curl -v localhost:8080/api/echo?message=aaaaaaaaa
=> 401 error

curl -v localhost:8080/api/echo?message=aaaaaaaaa -H 'X-API-KEY: 0123456789'
=> aaaaaaaaa

2025年2月11日火曜日

Spring Boot で SAML 認証をする(署名無しバージョン)

前提

  • Java: 21
  • Spring Boot: 3.4.2
  • Keycloak: 26

開発環境の起動

詳細は docker-compose.yaml を参照。

Spring Initilizr

this

Keycloak の設定

  1. http://localhost:8080/ へ接続し、以下情報でログイン
    • ユーザー名: admin
    • パスワード: password
  2. レルムの作成
    • レルム名: myrealm
  3. クライアントの作成
    1. Clients > Create client
      • Client type: SAML
      • Client ID: saml-sp
      • Name: SAML Practice
    2. Settings タブで必要事項を設定
      • Root URL: http://localhost:8081/
      • Valid redirect URIs: http://localhost:8081/*
    3. Keys タブで必要事項を設定
      • Client signature required: Off
  4. ユーザーの追加
    1. Users > Add user で、必要事項を記入して Create 押下
      • Username: test
    2. Credentials タブで Set password を押下し、必要事項を記入し Save
      • Password: test
      • Password confirmation: test
      • Temporary: Off

依存を追加

pom.xml に Shiboleth のリポジトリを追加し、 spring-security-saml2-service-provider の dependency を追加する。

<repositories>
  <repository>
    <id>shibboleth</id>
    <name>Shibboleth Repository</name>
    <url>https://build.shibboleth.net/nexus/content/repositories/releases/</url>
    <releases>
      <enabled>true</enabled>
    </releases>
    <snapshots>
      <enabled>false</enabled>
    </snapshots>
  </repository>
</repositories>
...(snip
  <dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-saml2-service-provider</artifactId>
  </dependency>
...(snip

Security のコンフィギュレーションを作成

package dev.mikoto2000.study.saml.firststep.configuration;

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.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests(auth -> auth
          .requestMatchers("/", "/public").permitAll()
          .anyRequest().authenticated()
          )
      .saml2Login(saml2 -> saml2
          .loginProcessingUrl("/login/saml2/sso/myrealm")
          )
      .logout(logout -> logout
          .logoutSuccessUrl("/")
          );
    return http.build();
  }
}

署名に使うファイル群の作成

# 1. 秘密鍵を作成
openssl genpkey -algorithm RSA -out private.key

# 2. 証明書リクエストを作成
openssl req -new -key private.key -out cert.csr \
    -subj "/C=JP/ST=Tokyo/L=Shibuya/O=ExampleCorp/OU=IT/CN=localhost"

# 3. 証明書を発行
openssl x509 -req -days 3650 -in cert.csr -signkey private.key -out certificate.crt

# 4. 秘密鍵と証明書をクラスパス直下に移動
mv certificate.crt private.key ./src/main/resources/

アプリケーション設定

spring:
  security:
    saml2:
      relyingparty:
        registration:
          myrealm:
            # Keycloak の Client ID と合わせる
            entity-id: "saml-sp"
            signing:
              credentials:
                # さっき作った証明書などの情報を入力
                - private-key-location: "classpath:private.key"
                  certificate-location: "classpath:certificate.crt"
                  private-key-password: "changeit"
                  private-key-alias: "saml-key"
                  key-store-password: "changeit"
            assertingparty:
              metadata-uri: "http://keycloak:8080/realms/myrealm/protocol/saml/descriptor"

# デバッグ出力を有効化
logging:
  level:
    org:
      springframework.web: debug

# Keycloak とポートがかぶるのでこっちを変更
server:
  port: 8081

表示するページの作成

以下静的ページを src/main/resources/static 下に格納する。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>test</title>
</head>
<body>
  Hello, World!
</body>
</html>

hosts ファイルの更新

hosts ファイルに以下エントリーを追加。

127.0.0.1 keycloak

動作確認

  1. http://localhost:8081/index.html へ接続すると、 keycloak へリダイレクトされる
  2. 先ほど作った test ユーザーでログインすると、 index.html が表示される

OK.

2023年7月26日水曜日

Java で Protocol Buffers をやる

前提

  • Windows 11 Pro 22H2 22621.1848
  • Docker Desktop version 4.20.1 (110738)
  • 使用する Docker イメージ: eclipse-temurin:17

Maven プロジェクト作成

spring initializr で、 Mavem + Java17 + Lombok のプロジェクトを作成し、展開する

開発用コンテナ起動

共通ボリュームとして maven_data を利用しているので、あらかじめボリュームを作成しておいてください。

docker volume create maven_data

そのうえで、 docker compose コマンドで開発用コンテナを立ち上げます。

docker compose up -d

開発用コンテナへ接続

以下コマンドで app コンテナへ接続し、その中で開発を行ってください。

docker compose exec app bash

Protocol Buffers に必要なパッケージのインストール

apt update
apt install -y protobuf-compiler

Protocol Buffers のコンパイルに必要なライブラリを pom.xml に追加

  • protobuf-java : 生成する Java ソースコードをコンパイルするためのライブラリ
  • protobuf-java-util : Protocol Buffers 用便利 API を使うためのライブラリ
        <dependency>
            <groupId>com.google.protobuf</groupId>
            <artifactId>protobuf-java</artifactId>
            <version>3.23.0</version>
        </dependency>
        <dependency>
            <groupId>com.google.protobuf</groupId>
            <artifactId>protobuf-java-util</artifactId>
            <version>3.23.0</version>
        </dependency>

.proto の作成

通信フォーマットを定義する .proto ファイルを作成する。

今回は、 ${PROJECT_ROOT}/proto/MemberInfo.proto に以下内容でファイルを作成。

syntax = "proto3";

option java_multiple_files = false;
option java_package="dev.mikoto2000.javastudy.protocolbuffers.firststep.model";

message CommonInfo {
  string timestamp = 1;
}

message TeacherProps {
  string teacher_id = 1;
}

message StudentProps {
  string student_id = 1;
}

message Member {
  string type = 1;
  string name = 2;
  oneof properties {
    TeacherProps teacher_props = 3;
    StudentProps student_props = 4;
  }
}

message MemberInfo {
  CommonInfo common_info = 1;
  repeated Member member = 2;
}

.proto から Java コードを生成

以下コマンドで、 Protocol Buffers のメッセージ作成に必要な Java コードを生成する。

protoc -I=./proto --java_out=./src/main/java/ ./proto/MemberInfo.proto

${PROJECT_ROOT}/src/main/dev/mikoto2000/javastudy/protocolbuffers/firststep/model/MemberInfoOuterClass.java にコードが生成される。

Protocol Buffers のエンコード・デコードを実装

簡単のために Spring Boot の CommandLineRunner を利用して処理を実装する。

package dev.mikoto2000.javastudy.protocolbuffers.firststep;

import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;

import com.google.protobuf.InvalidProtocolBufferException;

import dev.mikoto2000.javastudy.protocolbuffers.firststep.model.MemberInfoOuterClass.CommonInfo;
import dev.mikoto2000.javastudy.protocolbuffers.firststep.model.MemberInfoOuterClass.Member;
import dev.mikoto2000.javastudy.protocolbuffers.firststep.model.MemberInfoOuterClass.MemberInfo;
import dev.mikoto2000.javastudy.protocolbuffers.firststep.model.MemberInfoOuterClass.StudentProps;
import dev.mikoto2000.javastudy.protocolbuffers.firststep.model.MemberInfoOuterClass.TeacherProps;

/**
 * CliEntrypoint
 */
@Component
@Profile("!test")
public class CliEntrypoint implements CommandLineRunner {
    @Override
    public void run(String... args) throws InvalidProtocolBufferException {
        // MemberInfo の組み立て
        MemberInfo.Builder builder = MemberInfo.newBuilder();
        MemberInfo memberInfo = builder
            .setCommonInfo(CommonInfo.newBuilder().setTimestamp("1234567890").build())
            .addMember(Member.newBuilder()
                    .setType("student")
                    .setStudentProps(StudentProps.newBuilder()
                        .setStudentId("mikoto2000")
                        .build())
                    .build())
            .addMember(Member.newBuilder()
                    .setType("teacher")
                    .setTeacherProps(TeacherProps.newBuilder()
                        .setTeacherId("makoto2000")
                        .build())
                    .build())
            .build();

        System.out.printf("memberInfo: %s\n", memberInfo);

        // エンコード
        byte[] memberInfoBytes = memberInfo.toByteArray();

        // デコード
        MemberInfo memberInfoFromByteArray = MemberInfo.parseFrom(memberInfoBytes);

        System.out.printf("memberInfoFromByteArray: %s\n", memberInfoFromByteArray);

        // エンコード前後の結果比較
        System.out.printf("memberInfo.toString().equals(memberInfoFromByteArray.toString()): %s\n", memberInfo.toString().equals(memberInfoFromByteArray.toString()));
    }
}

動作確認

./mvnw spring-boot:run

以下のような出力になる。(Maven の出力や、 Spring Boot のロゴは省略)

memberInfo: common_info {
  timestamp: "1234567890"
}
member {
  type: "student"
  student_props {
    student_id: "mikoto2000"
  }
}
member {
  type: "teacher"
  teacher_props {
    teacher_id: "makoto2000"
  }
}

memberInfoFromByteArray: common_info {
  timestamp: "1234567890"
}
member {
  type: "student"
  student_props {
    student_id: "mikoto2000"
  }
}
member {
  type: "teacher"
  teacher_props {
    teacher_id: "makoto2000"
  }
}

memberInfo.toString().equals(memberInfoFromByteArray.toString()): true

うん、できている気がする。

以上。

参考資料

2021年6月22日火曜日

Spring Boot と Keycloak でアクセス制御したい

やっていく。

Path 権限
/ ログインしている人なら誰でもログイン可能
/users/user1 user1 のみログイン可能
/users/user2 user2 のみログイン可能
  • / に、各ユーザー用ページへのリンクを表示
  • ログインできないユーザーのページリンクがあっても無駄なので、 自分のページリンクのみを表示したい

前提

構築イメージ

+----------+
| Keycloak |
+----------+
 ↑ localhost:8080
+-----------------------+
| SpringBootApplication |
+-----------------------+
 ↑ localhost:8081
+---------+
| Browser |
+---------+

OAuth クライアントの追加

  1. http://localhost:8080 へアクセスし、 admin でログイン
  2. Clients -> Create ボタン押下
  3. Add Client ページが表示されるので、必要事項を記入して Save ボタン押下
    • Client ID : spring-boot
    • Client Protocol : openid-connect
    • Root URL : http://localhost:8081
  4. spring-boot の設定ページが表示されるため、必要な項目を更新して Save ボタン押下

Spring Boot アプリケーション作成

プロジェクトのひな形作成

Spring Initializr でプロジェクトのひな形を作成する。

今回使うのは こちら。

Keycloak の依存を追加

Securing Applications and Services Guide に従い、 keycloak-spring-boot-starter と keycloak-adapter-bom を追加する。

最終的な pom.xml は以下。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.1</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>dev.mikoto2000.study.springboot.keycloak</groupId>
    <artifactId>gettingstarted</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>gettingstarted</name>
    <description>Demo project for Spring Boot with Keycloak</description>
    <properties>
        <java.version>11</java.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.keycloak.bom</groupId>
                <artifactId>keycloak-adapter-bom</artifactId>
                <version>12.0.1</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.keycloak</groupId>
            <artifactId>keycloak-spring-boot-starter</artifactId>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

application.properties の修正

# server port
server.port=8081

# Keycloak
keycloak.auth-server-url=http://localhost:8080/auth

# レルム名を設定する。
keycloak.realm=MyApp

# クライアントIDを設定する。
keycloak.resource=spring-boot
keycloak.public-client=true

# OpenID ConnectのIDトークン属性を設定。
keycloak.principal-attribute=preferred_username

# ディレクトリと、アクセス許可のロールを定義
keycloak.security-constraints[0].authRoles[0]=authorized
keycloak.security-constraints[0].securityCollections[0].patterns[0]=/
keycloak.security-constraints[1].authRoles[0]=user1
keycloak.security-constraints[1].securityCollections[0].patterns[0]=/users/user1
keycloak.security-constraints[2].authRoles[0]=user2
keycloak.security-constraints[2].securityCollections[0].patterns[0]=/users/user2

アプリケーション実装

コントローラーとテンプレートを作る。

コントローラー

package dev.mikoto2000.study.springboot.keycloak.gettingstarted;

import java.security.Principal;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

/**
 * AppController
 */
@Controller
public class AppController {
    @GetMapping(path = "/")
    public String index(Principal principal, Model model) {
        model.addAttribute("username", principal.getName());

        return "index";
    }

    @GetMapping(path = "/users/{name}")
    public String customers(@PathVariable("name") String name, Principal principal, Model model) {

        model.addAttribute("username", name);
        return "userpage";
    }
}

テンプレート

index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>index</title>
</head>
<body>
    <p th:text="'Hello, ' + ${username}"></p>

    <ul>
        <li><a th:href="'./users/' + ${username}" th:text="'./users/' + ${username}"></a></li>
    </ul>
</body>
</html>

userpage.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title th:text="${username} + '\'s page'"></title>
</head>
<body>
    <p th:text="'Hello, ' + ${username} + '!'"></p>
</body>
</html>

動作確認

.\mvnw.cmd spring-boot:run して http://localhost:8081 へアクセス。

user1 や user2 でログインして、別ユーザーのユーザーページが見れないことを確認。

今回の方法だと、存在しないユーザーが分かってしまうが今はとりあえずいいや…。

後は、ログアウトとかエラーページの整備もやらないとですね。

参考資料