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

Java

こちらの記事では Spring Boot と JWT を使用して認可機能の実装をしていきます。別の記事で紹介した認証機能の続きの内容となりますので、まだ認証機能を実装されていない方は前回の記事をご確認ください。また前回同様に今回も Spring Security を使用せずに認可機能を実装していきます。

前回の記事はこちら
Spring Boot × JWTで認証機能を実装する(Spring Security未使用)

また本来であればDBに登録された Users テーブルの Role カラムに各ユーザーの権限情報(admin や user など)があるかと思うのですが、今回は認証・認可の実装にフォーカスしているためDBは使用しておりません。

この記事では主に以下の内容を紹介していきます。

  • 認証時の拡張:JWTへのRole情報の埋め込み
  • リクエストごとの権限チェック:Role情報に基づいた認可処理

開発環境

  • 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
        │   └── additional-spring-configuration-metadata.json
        ├── static
        └── templates
            ├── admin.html
            ├── 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を削除し、ログイン画面へリダイレクト
管理者画面/adminN/A認証時に発行されたJWTのRole情報がadminであれば管理者画面に遷移。adminでなければダッシュボード画面へリダイレクト

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

※JwtUtil クラス全体のプログラムは末尾に配置しています。

JWT作成メソッド内にRole情報のクレームを追加しています。

    public String generateToken(String userId, String role) {
        return Jwts.builder()
                .subject(userId)
                .claim("role", role)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey()) // 署名にHMAC-SHAアルゴリズムを使用
                .compact(); // トークンを生成して返す
    }

認証済みユーザーからのリクエストに含まれるRole情報を取得しまう。

    // ロール抽出メソッドを追加
    public String extractRole(String token) {
        Claims claims = Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
        return claims.get("role", String.class);
    }

全体プログラム(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.Claims;
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, String role) {
        return Jwts.builder()
                .subject(userId)
                .claim("role", role)
                .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 "の部分を除去
    // }


    // ロール抽出メソッドを追加
    public String extractRole(String token) {
        Claims claims = Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
        return claims.get("role", String.class);
    }

}

Role情報の付与(LoginController)

認証時にログインユーザーに付与するRole情報を先ほど作成したJwtUtilクラスに渡します。これによりユーザーのRole情報(admin もしくは user)がJWTに設定され以降のJWTを用いた認可に使用されます。

ユーザーID:admin / パスワード:password123 の場合 → admin権限
ユーザーID:user / パスワード:password123 の場合 → user権限

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 role = "admin"; // 役割(admin)を指定
            String token = jwtUtil.generateToken(user.getUserId(), role);
            System.out.println("Generated Token Admin: " + 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", "ログイン成功 : 管理者"));

        } else if ("user".equals(user.getUserId()) && "password123".equals(user.getPassword())) {
            String role = "user"; // 役割(user)を指定
            String token = jwtUtil.generateToken(user.getUserId(), role);
            System.out.println("Generated Token User: " + 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またはパスワードが間違っています"));
    }
}

管理者画面へのルーティング追加(RouteController)

認証後のダッシュボード画面から管理者画面へのルーティングを設定します。Role情報がadminでなければ、ダッシュボード画面へリダイレクトされます。

package jp.co.spring.Controller;

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

import jakarta.servlet.http.HttpServletRequest;

@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";
    }

    @GetMapping("/admin")
    public String adminPage(HttpServletRequest request) {
        String role = (String) request.getAttribute("role");
        if(!"admin".equals(role)) {
            return "redirect:/dashboard"; // 権限がない場合はダッシュボードへリダイレクト
        }
        return "admin"; // admin.htmlを返す
    }
    
}

認可チェック(JwtAuthenticationFilter)

admin権限を持つユーザーしかアクセスできないパスにリクエストが来た際にそのユーザーがadmin権限を持つユーザーかどうか(フィルター)チェックを行います。

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;
        }

        String token = jwtUtil.extractToken(request);
        if (token != null && jwtUtil.validateToken(token)) {
            String userId = jwtUtil.extracUserId(token);
            String role = jwtUtil.extractRole(token);
            System.out.println("Authenticated User ID: " + userId + ", Role: " + role);

            // admin専用パスの認可チェック
            if (path.startsWith("/admin") && !"admin".equals(role)) {
                System.out.println("Access denied for non-admin user to admin path");
                response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                response.setContentType("application/json;charset=UTF-8");
                response.getWriter().write("{\"error\":\"Forbidden\"}");
                return;
            }
            
            // コントローラー側で利用できるようにリクエスト属性にセット
            request.setAttribute("userId", userId);
            request.setAttribute("role", role);
            filterChain.doFilter(request, response);
            return;
        } else {
            System.out.println("Invalid or missing JWT token");
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"error\":\"Unauthorized\"}");
            return;
        }
    }
} 

画面修正および作成(View)

①ダッシュボード画面(追記)
ダッシュボード画面上で管理者画面への遷移ボタン作成

<!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>
            
            <!-- 管理者ページへのリンク -->
            <a th:if="${role == 'admin'}" href="/admin" class="verify-btn" style="display:inline-block; text-decoration:none;">管理者ページへ</a>

            <!-- ログアウト -->
            <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>

②管理者画面(新規)
管理者画面の作成

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>管理者ページ</title>
</head>
<body>
    <h1>管理者専用ページ</h1>
    <p>ロール: <span th:text="${role}"></span></p>
    <p>ここはadminロールのみアクセス可能です。</p>
    <a href="/dashboard">ダッシュボードに戻る</a>
</body>
</html>

今回は認可についての実装を行いました。認可によってユーザーごとにデータや機能へのアクセスを制限することが可能となります。

コメント

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