Spring Boot × JWTで認証機能を実装する(Spring Security未使用)

Java

Spring Boot と JWT を使用して認証機能を作成します。なお、今回は Spring Security は使用せずに実装します。フレームワークを使用せずにJWTの作成から認証までの処理を細かく確認したいと思ったからです。

Spring Securityを使えば高度な認証を短時間で実装できます。しかし、その裏側で『いつ、どこでトークンが解析され、どうやってユーザーが特定されているのか』が見えにくいこともあります。今回は、自らコントローラーやユーティリティクラス(JwtUtil)を書くことで、その通信の全容を可視化することを目指しました。

※今回は学習用なのでシンプルにしていますが、本番環境ではSpring Securityのような堅牢なフレームワークを使うことを推奨します。またデータベースは使用していません。あくまでJWTの認証にフォーカスしておりますので。

この記事で紹介していることは主に以下の内容となります。

  • JWTの生成と検証:JJWTライブラリを使ったトークンの発行と解析
  • リクエストフィルタの実装:OncePerRequestFilter を使った共通認証処理
  • リクエスト解析:HTTPヘッダー(Cookie)からトークンを取り出す実装


開発環境

  • Java 21 / Spring Boot 4.0.1
  • jjwt 0.13
  • Maven


画面遷移


./spring
├── HELP.md
├── mvnw
├── mvnw.cmd
├── pom.xml
├── src
│   ├── main
│   │   ├── java
│   │   │   └── jp
│   │   │       └── co
│   │   │           └── spring
│   │   │               ├── annotation
│   │   │               ├── Application.java
│   │   │               ├── config
│   │   │               │   └── WebConfig.java
│   │   │               ├── Controller
│   │   │               │   ├── JwtAuthenticationFilter.java
│   │   │               │   ├── LoginController.java
│   │   │               │   ├── LogoutController.java
│   │   │               │   ├── RouteController.java
│   │   │               │   └── VerifyTokenController.java
│   │   │               ├── Entity
│   │   │               │   └── LoginRequest.java
│   │   │               └── util
│   │   │                   └── JwtUtil.java
│   │   └── resources
│   │       ├── application.properties
│   │       ├── META-INF
│   │       ├── static
│   │       └── templates
│   │           ├── dashboard.html
│   │           ├── index.html
│   │           ├── login.html
│   │           └── token-valid.html

JWTについて知りたい方は合わせてこちらを参考にしてみてください。


画面遷移とAPIの連動まとめ

今回の認証システムにおける各画面の役割と通信の流れは以下の通りです。

画面名遷移用URL呼び出すAPI期待する結果
インデックス/N/Aログイン画面へのリンクを表示
ログイン/login/api/loginID/PWを検証し、CookieにJWTを保存してダッシュボードへ
ダッシュボード/dashboard/api/verify-token「Token検証」ボタンで現在のトークンの有効性を確認
検証完了/token-validN/A検証成功時に遷移。「トークンは有効です」と表示
N/AN/A/api/logoutCookie内のJWTを削除し、ログイン画面へリダイレクト

遷移用URLで画面に遷移し、その画面内にあるボタンなどを押下することでAPIが実行されます。


依存関係の設定 (pom.xml)

まずはJWTを扱うためのライブラリ「JJWT」を導入します。JavaでJWTをゼロから生成するのは大変ですが、JJWTを使うと署名や有効期限の管理が数行で書けるようになります。

<?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>4.0.1</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>jp.co</groupId>
	<artifactId>spring</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>spring</name>
	<description>Demo project for Spring Boot</description>
	<url/>
	<licenses>
		<license/>
	</licenses>
	<developers>
		<developer/>
	</developers>
	<scm>
		<connection/>
		<developerConnection/>
		<tag/>
		<url/>
	</scm>
	<properties>
		<java.version>21</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-h2console</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-webmvc</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>com.h2database</groupId>
			<artifactId>h2</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-webmvc-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-actuator</artifactId>
		</dependency>
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-api</artifactId>
			<version>0.13.0</version>
		</dependency>
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-impl</artifactId>
			<version>0.13.0</version>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
			<version>0.13.0</version>
			<scope>runtime</scope>
		</dependency>
	</dependencies>

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

</project>

JJWT の Maven Repository は以下
https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api


秘密鍵の設定 (application.properties)

JWTの「署名(改ざん防止)」に使う秘密鍵を定義します。秘密鍵は漏洩すると誰でもトークンが偽造できてしまうため、本来は環境変数などで管理すべき重要な値です。

spring.application.name=spring

# JWT署名用の秘密鍵(本来はもっと長く複雑な文字列にする)
jwt.secret=your-very-long-random-base64-string-at-least-32-chars-long
# JWTの有効期限(ミリ秒単位)
jwt.expiration=3600000



JWT操作の共通クラス (JwtUtil)

準備が整ったら、JWTの生成・検証・抽出を一手に行う JwtUtil クラスを作成します。JwtUtil クラス全体のプログラムは末尾に配置しています

  • 署名鍵の作成(getSigningKey)
private SecretKey getSigningKey() {
    return Keys.hmacShaKeyFor(secret.getBytes());
}

JWTの信頼性を担保する「署名」のための鍵を用意します。JJWTの最新バージョンでは秘密鍵をそのまま使うのではなくSecretKey オブジェクトに変換して渡す必要があります。


  • トークンの生成(generateToken)
public String generateToken(String userId) {
    return Jwts.builder()
            .subject(userId)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(getSigningKey())
            .compact();
}

ログイン成功時に、ユーザーID(Subject)を封じ込めたトークンを発行します。
ここで「誰のトークンか(subject)」「いつ発行したか(issuedAt)」「いつまで有効か(expiration)」をセットし、最後に秘密鍵でサインをしています。

JwtBuilderで使用可能なメソッドは以下から確認できます。

JwtBuilder - jjwt-api 0.13.0 javadoc


  • トークンの抽出 (extractToken)
public String extractToken(HttpServletRequest request) {
    // Cookieから "jwt-token" という名前の値を探す
    if (request.getCookies() != null) {
        for (Cookie cookie : request.getCookies()) {
            if ("jwt-token".equals(cookie.getName())) {
                return cookie.getValue();
            }
        }
    }
    return null;
}

ここが今回の実装のユニークな点です。
一般的なAPIでは Authorization ヘッダーを使いますが、今回はブラウザでの管理を容易にするために Cookie からトークンを取り出す設計にしています。これにより、JavaScriptでヘッダーを制御しなくても、ブラウザが自動でトークンを運んでくれるメリットがあります。

Header vs Cookie、どっちがいい?
本記事では実装のしやすさから Cookie を採用していますが、スマホアプリとの連携など「ブラウザ以外」からのアクセスも想定する場合は、Authorizationヘッダー(Bearer形式) を使うのが一般的です。Authorizationヘッダーを想定した実装は以下になります。

public String extractToken(HttpServletRequest request) {    
       String token = request.getHeader("Authorization");
       if (token != null && token.startsWith("Bearer ")) {
           return token.substring(7); // "Bearer "の部分を除去
       }
        return null;
}


  • トークンの有効性チェック (validateToken)
public boolean validateToken(String token) {
    try {
        Jwts.parser()
            .verifyWith(getSigningKey()) // 秘密鍵で署名を検証
            .build()
            .parseSignedClaims(token);   // 解析(不正があれば例外が発生)
        return true;
    } catch (Exception e) {
        return false; // 改ざんや期限切れの場合はここ
    }
}

クライアントから届いたトークンが信頼できるかをチェックします。JJWTライブラリの parseSignedClaims メソッドを呼ぶだけで、署名の検証と有効期限のチェックを同時に行ってくれます。もしトークンが改ざんされていたり、1時間(設定値)を過ぎていたりすると例外が発生するため、false を返すことで不正なアクセスをブロックできます。


  • HTTPリクエストからのトークン抽出 (extractToken)
public String extractToken(HttpServletRequest request) {
    // Cookieを全スキャンして "jwt-token" を探す
    if (request.getCookies() != null) {
        for (Cookie cookie : request.getCookies()) {
            if ("jwt-token".equals(cookie.getName())) {
                return cookie.getValue();
            }
        }
    }
    return null;
}

通常、API開発ではAuthorization ヘッダーから抽出することが多いですが、今回はCookieを使用しています。Cookieを使用するメリットは、ブラウザが自動的にリクエストに含めてくれるため、フロントエンド(JavaScript)側での実装がシンプルになる点です。


  • ユーザーIDの特定 (extracUserId)
public String extracUserId(String token) {
    return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload() // 内容(ペイロード)を取得
            .getSubject(); // セットしたuserIdを取り出す
}

検証をパスした後そのトークンが「誰のものか」を特定します。
検証が終わったら、中身(ペイロード)を取り出します。generateTokensubject にセットした userId をここで復元することで、サーバー側は『このリクエストはユーザーAさんからのものだ』と確信を持って処理を続けられます。


全体プログラム(JwtUtil.java)

package jp.co.spring.util;

import java.util.Date;

import javax.crypto.SecretKey;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;

@Component
public class JwtUtil {

    @Value("${jwt.expiration:3600000}") // 1時間
    private long expiration;

    @Value("${jwt.secret:mySecretKeyForJwtTokenGenerationAndValidation12345}") // デフォルトのシークレットキー
    private String secret;

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes());
    }
    
    // ダミーのトークン生成ロジック(実装例)
    public String generateToken(String userId) {
        return Jwts.builder()
                .subject(userId)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey()) // 署名にHMAC-SHAアルゴリズムを使用
                .compact(); // トークンを生成して返す
    }

    // ダミーのトークン検証ロジック(実装例)
    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public String extractToken(HttpServletRequest request) {    
        // Cookieから "jwt-token" という名前の値を探す
        if (request.getCookies() != null) {
            for (Cookie cookie : request.getCookies()) {
                if ("jwt-token".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }
    
    public String extracUserId(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getSubject();
    }

        // Authorizationヘッダはクライアント側からのリクエスト時に使用されることが多いが今回は使用していない。
        // もし使用する場合は、以下のようにヘッダーからトークンを抽出するコードを追加する。
        // String token = request.getHeader("Authorization");
        // if (token != null && token.startsWith("Bearer ")) {
        //     return token.substring(7); // "Bearer "の部分を除去
        // }
}



コアロジックの実装(LoginRequest)

JWT認証の操作を行うUtilが作成できたら、次は「どのようなデータ受け取るか」というデータの型(Entity)を定義します。

クライアント(ログイン画面)から送られてくるユーザーIDとパスワードを受け取るための、シンプルなJavaオブジェクト(DTO)です。

package jp.co.spring.Entity;

public class LoginRequest {
    
    private String userId;
    private String password;

    // デフォルトコンストラクタ
    public LoginRequest() {
    }

    // 便利なコンストラクタ
    public LoginRequest(String userId, String password) {
        this.userId = userId;
        this.password = password;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

Spring Bootでは、リクエストのJSONデータを自動的にJavaオブジェクトに変換(デシリアライズ)してくれます。この LoginRequest クラスを用意しておくことでコントローラー側で user.getUserId() のように型安全にデータを取り出すことができます。

今回は実装していませんがデータベースのテーブル構造をそのまま表す『Entity』と、画面からの入力を受け取る『Request』を分けておくことで、将来的に『パスワード確認用フィールド』を増やしたり、特定の項目を隠したりといった変更が容易になります。



ログイン機能の実装(LoginController)

このコントローラーは、ログイン画面の表示と、認証処理(ログイン実行)の2つの役割を担います。LoginController クラス全体のプログラムは末尾に配置しています。

  • ログイン画面の表示 (@GetMapping)
@GetMapping("/login")
public String loginForm() {
    return "login"; // templates/login.html を表示
}

まずはユーザーがアクセスするログインページを返します。

  • 認証処理とCookieの発行 (@PostMapping)
@PostMapping("/api/login")
@ResponseBody
public ResponseEntity<Map<String, String>> loginProcess(@RequestBody LoginRequest user) {
     // 簡易的な認証(実装例)
     if ("admin".equals(user.getUserId()) && "password123".equals(user.getPassword())) {
         // JWTトークンの作成
         String token = jwtUtil.generateToken(user.getUserId());
         System.out.println("Generated Token: " + token);
     
         // Cookieの作成(ブラウザに保存される設定)
         ResponseCookie cookie = ResponseCookie.from("jwt-token", token)
                 .httpOnly(true)  // JavaScriptからのアクセスを防止
                 .secure(false)   // HTTPSを使用する場合はtrueに設定
                 .path("/")       // クッキーの有効パス
                 .maxAge(60 * 60) // Tokenと同じ1時間の有効期限に設定
                 .sameSite("Lax") // CSRF対策
                 .build();
            
         // ResponseEntityのヘッダーにクッキーを追加して返す
         HttpHeaders headers = new HttpHeaders();
         headers.add(HttpHeaders.SET_COOKIE, cookie.toString());

         return ResponseEntity.ok()
                 .headers(headers)
                 .body(Map.of("message", "ログイン成功"));
     }
     return ResponseEntity.status(401).body(Map.of("message", "ユーザーIDまたはパスワードが間違っています"));
}

ユーザーがIDとパスワードを送信した際のメインロジックです。

  • httpOnly(true): これを設定することで、ブラウザ上のJavaScriptからCookieを読み取れなくなります。万が一、悪意のあるスクリプトが実行されても(XSS攻撃)、トークンが盗まれるリスクを大幅に下げられます。
  • sameSite(“Lax”): 別のサイトから意図しないリクエストが送られる「CSRF攻撃」を防止するための設定です。

「ステートレス」な認証の第一歩
この処理が終わるとブラウザのCookieにはサーバーから渡されたJWTが保存されます。以降のリクエストでは、サーバー側でセッションを保持していなくてもこのCookieを見るだけで誰がアクセスしているか判断できるようになります。


全体プログラム(LoginController.java)

package jp.co.spring.Controller;

import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseBody;

import jp.co.spring.Entity.LoginRequest;
import jp.co.spring.util.JwtUtil;

@Controller
public class LoginController {

    @Autowired
    private JwtUtil jwtUtil;
    
    @GetMapping("/login")
    public String loginForm() {
        return "login";
    }
    
    @PostMapping("/api/login")
    @ResponseBody
    public ResponseEntity<Map<String, String>> loginProcess(@RequestBody LoginRequest user) {
        // 簡易的な認証(実装例)
        if ("admin".equals(user.getUserId()) && "password123".equals(user.getPassword())) {
            String token = jwtUtil.generateToken(user.getUserId());
            System.out.println("Generated Token: " + token);

            ResponseCookie cookie = ResponseCookie.from("jwt-token", token)
                    .httpOnly(true) // JavaScriptからのアクセスを防止
                    .secure(false) // HTTPSを使用する場合はtrueに設定
                    .path("/") // クッキーの有効パス
                    .maxAge(60 * 60) // Tokenと同じ1時間の有効期限に設定
                    .sameSite("Lax") // CSRF対策
                    .build();
            
            // ResponseEntityのヘッダーにクッキーを追加して返す
            HttpHeaders headers = new HttpHeaders();
            headers.add(HttpHeaders.SET_COOKIE, cookie.toString());

            return ResponseEntity.ok()
                    .headers(headers)
                    .body(Map.of("message", "ログイン成功"));
        }
        return ResponseEntity.status(401).body(Map.of("message", "ユーザーIDまたはパスワードが間違っています"));
    }
}



共通認証の実装(JwtAuthenticationFilter / WebConfig)

ここでは、「全てのリクエストを待ち伏せしてチェックする仕組み」を作ります。

認証フィルタの実装を行います。OncePerRequestFilter を継承することで、1回のリクエストに対して確実に1回だけ実行されるフィルタを作成します。

package jp.co.spring.Controller;

import java.io.IOException;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jp.co.spring.util.JwtUtil;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) 
                                    throws ServletException, IOException {
        
        // フィルタリングロジックをここに実装
        String path = request.getRequestURI();
        System.out.println("Request Path: " + path);
        
        // トークン不要な除外リスト(ログイン、静的リソースなど)
        if (path.equals("/") || path.startsWith("/next")|| path.startsWith("/login") || path.startsWith("/static") || path.startsWith("/api/login")
                || path.startsWith("/static/") || path.startsWith("/css/") || path.startsWith("/js/") || path.startsWith("/favicon.ico") || path.startsWith("/.well-known/")) {
            filterChain.doFilter(request, response);
            return;
        }
        // Cookieからトークンを抽出
        String token = jwtUtil.extractToken(request);
        // トークンの有効性検証
        if (token != null && jwtUtil.validateToken(token)) {
            String userId = jwtUtil.extracUserId(token);
            System.out.println("Authenticated User ID: " + userId);
            // コントローラー側で利用できるようにリクエスト属性にセット
            request.setAttribute("userId", userId);
            filterChain.doFilter(request, response);
            return;
        } else {
            System.out.println("Invalid or missing JWT token");
            // 無効な場合は401エラーを返してブロック
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"error\":\"Unauthorized\"}");
            return;
        }
    }
} 
  • 除外リストの必要性:ログイン画面そのものや画像・CSSなどの静的ファイルにまで認証をかけると画面が正しく表示されなくなります。この『ホワイトリスト』管理が自作フィルタでは重要です。
  • request.setAttribute :認証に成功したユーザー情報(userId)を Attribute にセットしておくことで後続のコントローラー側で『今誰がログインしているか』を簡単に取得できるようになります。

フィルタ(JwtAuthenticationFilter.java)の有効化をこちらで行います。作成したフィルタをSpring Bootの仕組みの中に組み込みます。

package jp.co.spring.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import jp.co.spring.Controller.JwtAuthenticationFilter;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    // フィルタ適用の流れのイメージ:
    //     全リクエスト
    //     ↓
    // WebConfig: reg.addUrlPatterns("/*") 
    //     ↓「全てのパスに対してJwtAuthenticationFilterを実行する」
    //     ↓
    // JwtAuthenticationFilter.doFilter() 実行
    //     ↓
    //     ├─ パスが除外リストに該当 → filterChain.doFilter()で下流へ(認証スキップ)
    //     │  例:/login, /api/login, /static/... → そのままコントローラへ通す
    //     │
    //     └─ パスが除外リスト以外 → トークン検証
    //        ├─ 有効なトークン → userId をセットして下流へ
    //        └─ 無効/なし → 401エラーで即終了

    @Bean
    public FilterRegistrationBean<JwtAuthenticationFilter> jwtFilterRegistration() {
        FilterRegistrationBean<JwtAuthenticationFilter> reg = new FilterRegistrationBean<>(jwtAuthenticationFilter);
    reg.addUrlPatterns("/*"); // 全てのリクエストがフィルタを通る
        reg.setOrder(1);      // フィルタの実行順序(必要に応じて調整)
        reg.setName("JwtAuthenticationFilter");
        return reg;
    }
}

addUrlPatterns(“/*”): 特定のパスだけでなく、アプリ全体に網をかける設定です。これにより、新しいページを追加しても自動的に認証が適用される安全な設計になります。


※今回は使用していませんが、Interceptor との違いもここで押さえておきます。

特徴FilterInterceptor
所属Servletコンテナ(Springの外側)Spring MVC(Springの内側)
実行タイミングDispatcherServlet の前後Controller の前後
扱える対象リクエスト/レスポンスそのものSpringのBeanやControllerの情報
主な用途文字エンコード、認証、圧縮、ログ認可(権限チェック)、共通の表示データ作成

どちらも「メイン処理の前後に処理を挟む」という点では似ていますが、「どこで動作するか」「何を知っているか」が決定的に違います。




認証が必要な画面と機能の実装(RouteController / VerifyTokenController / LogoutController / View)

これまでに作った「仕組み」を使い、ユーザーが操作する画面(View)と、認証済みユーザーだけが叩けるAPI、そしてログアウト処理を連携させます。

まずは、各HTMLを表示するためのシンプルなルーティングです。

package jp.co.spring.Controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class RouteController {
    
    @GetMapping("/")
    public String indexPage() {
        return "index";
    }

    @GetMapping("/dashboard")
    public String dashboard(){
        return "dashboard";
    }

    @GetMapping("/token-valid")
    public String tokenValid() {
        return "token-valid";
    }
    
}

「共通認証の実装」で作成したフィルタにより、/dashboard や /token-valid へのアクセスは、有効なトークンを持っていないと自動的に弾かれます。

ダッシュボード上の「Token検証」ボタンを押したときに動く仕組みです。「検証」リクエストを捌く専用のコントローラーです。

package jp.co.spring.Controller;

import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import jakarta.servlet.http.HttpServletRequest;
import jp.co.spring.util.JwtUtil;

@Controller
public class VerifyTokenController {

    @Autowired
    private JwtUtil jwtUtil;

    @PostMapping("/api/verify-token")
    @ResponseBody
    public ResponseEntity<Map<String, String>> vertifyToken(HttpServletRequest request) {
        String token = jwtUtil.extractToken(request);
        if(token != null && jwtUtil.validateToken(token)) {
            String userId = jwtUtil.extracUserId(token);
            return ResponseEntity.ok(Map.of(
                "message", "トークンは有効です",
                "userId", userId
            ));
        }
        return ResponseEntity.status(401).body(Map.of("message", "無効なトークンです"));
    }
}

ブラウザに保存されたJWTを破棄します。

package jp.co.spring.Controller;

import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;

import jakarta.servlet.http.HttpServletResponse;

@Controller
public class LogoutController {

    // ログアウト処理(必要に応じて実装)
    @PostMapping("/api/logout")
    public String logout(HttpServletResponse response) {
        ResponseCookie cookie = ResponseCookie.from("jwt-token", "")
                .httpOnly(true)
                .secure(false)
                .path("/")
                .maxAge(0) // クッキーを削除
                .sameSite("Lax")
                .build();
        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
        return "redirect:/login";
    }
}

JWTは一度発行するとサーバー側から消すことができません。そのため、ログアウト処理では『有効期限0の空のCookie』を上書きで送り返し、ブラウザ側に保存されているトークンを強制的に上書き消去させるという手法をとります。

① 公開ページ (index.html)

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>index</title>
    </head>
    <body>
        <h1>Index Page</h1>
        <a th:href="@{/login}">Login Pageへ</a>
    </body>
</html>
  • 役割:未ログインユーザーに対し、ログインページへのリンクを提供します。
  • コードの肝: 認証フィルタの「除外パス」に設定されているため、トークンなしで表示可能です。


② ログイン画面 (login.html)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ログイン</title>
    <style>
        body { font-family: Arial, sans-serif; background-color: #f5f5f5; }
        .login-container { width: 300px; margin: 100px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
        h2 { text-align: center; color: #333; }
        .form-group { margin: 15px 0; }
        label { display: block; margin-bottom: 5px; color: #555; }
        input { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }
        button { width: 100%; padding: 10px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; margin-top: 10px; }
        button:hover { background-color: #0056b3; }
        .error { color: red; text-align: center; margin-bottom: 15px; }
    </style>
</head>
<body>
    <div class="login-container">
        <h2>ログイン</h2>
        <div th:if="${param.error}" class="error">ユーザーIDまたはパスワードが間違っています</div>
        <form method="post" action="/api/login" onsubmit="handleLogin(event)">
            <div class="form-group">
                <label for="userId">ユーザーID:</label>
                <input type="text" id="userId" name="userId" required>
            </div>
            <div class="form-group">
                <label for="password">パスワード:</label>
                <input type="password" id="password" name="password" required>
            </div>
            <button type="submit">ログイン</button>
        </form>
    </div>
    <script>
        async function handleLogin(event) {
            event.preventDefault();
            const userId = document.getElementById('userId').value;
            const password = document.getElementById('password').value;
    
            const response = await fetch('/api/login', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                credentials:"same-origin",
                body: JSON.stringify({ userId, password })
            });
    
            console.log(response);
    
            if (response.ok) {
                window.location.href = '/dashboard';
            } else {
                alert('ログインに失敗しました。ユーザーIDまたはパスワードを確認してください。');
            }
        }
    </script>
</body>
</html>
  • 役割: fetch APIで /api/login を叩き、成功時にサーバーから送られる jwt-token(Cookie)を受け取ります。
  • JSのポイント: credentials: "same-origin" を指定して、サーバーからの Set-Cookie をブラウザが受理できるようにします。


③ 管理画面 (dashboard.html)
認証されたユーザーだけが操作できるメイン画面です。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ダッシュボード</title>
    <style>
        body { font-family: Arial, sans-serif; background-color: #f5f5f5; margin: 0; }
        .navbar { background-color: #007bff; color: white; padding: 15px 20px; }
        .navbar h1 { margin: 0; font-size: 24px; }
        .container { max-width: 1000px; margin: 30px auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
        .user-info { margin-bottom: 20px; padding: 15px; background-color: #e7f3ff; border-left: 4px solid #007bff; }
        .button-group { margin-top: 20px; }
        .logout-btn, .verify-btn { color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px; }
        .logout-btn { background-color: #dc3545; }
        .logout-btn:hover { background-color: #c82333; }
        .verify-btn { background-color: #28a745; }
        .verify-btn:hover { background-color: #218838; }
    </style>
</head>
<body>
    <div class="navbar">
        <h1>ダッシュボード</h1>
    </div>
    <div class="container">
        <div class="user-info">
            <!-- <p>ログイン中のユーザーID: <strong th:text="${userId}"></strong></p> -->
        </div>
        <h2>ようこそ</h2>
        <p>システムへのログインに成功しました。</p>
        
        <div class="button-group">
            <!-- Token検証ボタン -->
            <button type="button" class="verify-btn" onclick="verifyToken()">Token検証</button>
            
            <!-- ログアウト -->
            <form action="/api/logout" method="post" style="display:inline;">
                <button type="submit" class="logout-btn">ログアウト</button>
            </form>
        </div>
    </div>
    <script>
        async function verifyToken() {
            const response = await fetch('/api/verify-token', {
                method: 'POST',
                credentials: 'same-origin' // クッキーを自動送信
            });

            const result = await response.json();
            
            if(response.ok) {
                alert('Token検証成功: ' + result.message);
                // 検証成功時に別ページへ遷移
                window.location.href = '/token-valid';
            } else {
                alert('Token検証失敗: ' + result.message);
                // 検証失敗時にログインページへリダイレクト
                window.location.href = '/login';
            }
        }
    </script>
</body>
</html>

ダッシュボードにある『Token検証』ボタンをクリックすると、裏側でVerifyTokenController が動きます。このとき、ブラウザはJWTをセットしているCookieを自動で添えてリクエストを出します。サーバーが『有効』と返せば、次の token-valid.html へ進むことができます。


④ 検証成功画面 (token-valid.html)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Token 有効</title>
</head>
<body>
    <div style="max-width:600px;margin:50px auto;padding:20px;background:#fff;border-radius:8px;text-align:center;">
        <h1>トークンは有効です</h1>
        <p>アクセスが許可されました。</p>
        <p><a href="/dashboard">ダッシュボードに戻る</a></p>
    </div>
</body>
</html>

「Token検証」をパスした証として表示されるゴール画面です。




画面遷移とAPIの連動まとめ(再掲示)

再度ここで記事冒頭に掲示した画面遷移とAPIの対応表について提示しておきます。

画面名遷移用URL呼び出すAPI期待する結果
インデックス/N/Aログイン画面へのリンクを表示
ログイン/login/api/loginID/PWを検証し、CookieにJWTを保存してダッシュボードへ
ダッシュボード/dashboard/api/verify-token「Token検証」ボタンで現在のトークンの有効性を確認
検証完了/token-validN/A検証成功時に遷移。「トークンは有効です」と表示
N/AN/A/api/logoutCookie内のJWTを削除し、ログイン画面へリダイレクト

ここまでがJWTを利用した認証の基本的な処理になります。なお冒頭でもお伝えしたとおり、やはりSpring Security を利用した方が堅牢な認証処理になると思われます。そちらは別途、記事にしていきます。

コメント

タイトルとURLをコピーしました