SpringSecurity

公開日: 2026-08-03 10:03 更新日: 2026-08-03 10:03 6660文字 34 min read ... ページビュー

この記事では、Spring Security 6エンタープライズセキュリティフレームワークのコアコンセプトと実装メカニズムを紹介し、認証とアクセス承認の基本プロセス、コアコンポーネント(SecurityFilterChain、Authentication Manager、User Details Serviceなど)、およびバックエンド分離シナリオにおけるJWT認証プラクティスに焦点を当てます。本稿では、セキュリティ構成の正確性、暗号符号化の規範性、CSRFおよびXSS保護の必要性を強調し、本番環境における適切な認証ストレージおよびセキュリティポリシーの選択を推奨します。

Spring Security 6エンタープライズセキュリティフレームワーク

Spring Securityの基礎

スプリングセキュリティとは?

Spring SecurityはSpringエコシステムのセキュリティフレームワークであり、サーブレットアプリケーションやリアクティブアプリケーションにおける認証、アクセス許可、一般的なセキュリティ保護を扱う。

Apache Shiroと比較して、Spring SecurityはSpring Boot、Spring MVC、OAuth 2.0などのコンポーネントとより緊密に統合されており、拡張ポイントも豊富です。フレームワークの能力は豊富であるため、まずフィルタチェーン、認証オブジェクト、認可ルールなどのコア概念を習得する必要があります。

認証と認可

エンタープライズアプリケーションは通常、2つの主要な問題に対処する必要がある。

  • 認証 Authentication 現在の訪問者が誰であるか、その身元が正当であるかどうかを確認します。
  • “アクセス許可”Authorization 認証されたユーザーが、リソースにアクセスしたり、アクションを実行したりする権限があるかどうかを確認します。

オンライン教育プラットフォームを例にとると

ユーザー·タイプ許可される操作
学生たちはコースを見る
教師はコースの公開
管理者のことユーザーの削除

認証は“あなたは誰ですか”に取り組み、権限は“あなたにできること”に取り組みます。

Spring Securityの主な特長

Spring Securityの一般的な機能:

  1. ユーザ名パスワード認証、証明書認証、ワンタイムトークン認証などのアイデンティティ認証メカニズム。
  2. URLレベルとメソッドレベルのアクセス許可。
  3. コードコードと検証。
  4. セッション管理、固定セッション攻撃の保護、同時セッション制御。
  5. CSRF 保護、セキュリティ対応トップのWebセキュリティ機能。
  6. OAuth 2.0クライアント、リソースサーバ、Open ID Connectの統合。

XSSは主に出力エンコーディング、コンテンツセキュリティポリシー、フロントエンドセキュリティ開発仕様によって保護されます。Spring Securityは部分的なセキュリティレスポンスヘッダーを設定できますが、ビジネスレベルのXSS 保護を置き換えることはできません。

CSRF

CSRFはCross-Site Request Forgeryです。攻撃者は、正当なサイトにログインしているユーザーに悪意のあるページを誘導し、ブラウザが自動的に正当なサイトのクッキーを運び、正当なサイトがリクエストをユーザー自身が開始したと誤解させることがあります。

XSS

XSSはクロスサイトスクリプティング攻撃の略。攻撃者は、他のユーザーがページにアクセスするとブラウザが実行する悪意のあるスクリプトをWebページに挿入し、情報の盗難、個人情報の不正使用、ページ改ざんを引き起こす可能性があります。

セッションモードとトークンモード

Spring Securityはステートフル認証とステートレス認証の両方をサポートする。バックエンドとバックエンドの分離はトークンのみを使用することを意味するものではありませんが、分散 APIではBearerトークンを使用するステートレスなスキームが一般的です。

の比較セッションステートフル認証Tokenステートレス認証
状態の位置サーバはセッションを保存し、クライアントは通常セッションCookieを保存する通常、サーバはログインセッションを保存せず、クライアントはアクセストークンを運ぶ
クラスターデプロイメントセッションコピー、共有ストレージ、スティッキーセッションが必要各ノードはトークンを独立に検証可能
ブラウザの自動搬送クッキーは通常ブラウザによって自動的に送信されます。Authorization要求ヘッダは通常、クライアントコードによってアクティブに追加される
キャンセルとキャンセルサーバーがセッションを削除するとすぐに無効になります短有効期限、ブラックリスト、トークンバージョンなどのメカニズムが必要
典型的なシーンサーバ侧レンダリングサイト、Webアプリケーションバックエンドとバックエンドの分離 API、マイクロサービスリソースサーバ

localStorageにトークンを入金すると、XSSによるトークン盗難のリスクが高まります。本番システムは、脅威モデルに基づいて、メモリ、保護されたCookie、またはその他のストレージスキームを選択する必要があります。ブラウザが自動的に運ぶCookieを使用する場合は、CSRF 保護を評価および設定する必要があります。

コア構造

image-001
image-001

Spring Securityのサーブレットセキュリティ機能は、フィルタチェーン上に構築されます。リクエストはビジネスコントローラに入る前に、Spring Securityが選択したSecurityFilterChainを通過します。

共通のコアコンポーネントは以下のとおりです。

アセンブリ作用
Chain現在のリクエストで実行する必要のあるセキュリティフィルタを宣言する
Chain要求に応じて対応するSecurityFilterChainを照合して実行する
Authentication認証リクエストまたは現在認証されているプリンシパルを表します
AuthenticationManager認証ポータルの定義
Authentication特定の認証方法の実施
UserDetailsServiceユーザー名によるユーザーのロード
PasswordEncoderパスワードのエンコードと検証
SecurityContext現在の要求の関連付けを保存するAuthentication
SecurityContextHolderSecurityContextへのアクセスのための統合ポータルを提供

+++主な面接質問:Spring Securityの主な役割は何ですか?

Spring Securityは、アプリケーション内の認証とアクセス許可の問題を解決し、パスワードエンコーディング、セッション管理、CSRF 保護、セキュリティレスポンスヘッダー、OAuth 2.0 統合などのセキュリティ機能を提供します。

+++

Spring Boot 3とSpring Securityを統合

環境要件は

このレッスンの例では、次の環境を使用します。

Spring Boot 3.3+
Java 17+
Spring Security 6
Maven
Vue 3

依存性の導入

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

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

最初のインターフェースの作成

@RestController
@RequestMapping("/user")
public class UserController {

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

プロジェクトを開始したら、http://localhost:8080/user/helloにアクセスします。カスタムセキュリティ構成がない場合、Spring BootはWebアプリケーションのデフォルトのセキュリティ構成を有効にし、ログインページを表示します。

image-002
image-002

デフォルト·ユーザー

プロジェクトにUserDetailsServiceAuthenticationProviderなどのカスタム認証構成がない場合、Spring Bootは通常、デフォルトユーザーを作成します。

username: user
password: 启动时随机生成

ランダムなパスワードがコンソールに出力されます。

image-003
image-003

デフォルトのユーザー名とパスワードをカスタマイズする

spring:
  security:
    user:
      name: admin
      password: 123456

この構成は、開始時のデモに適しており、本番環境には適していません。本番システムでは、データベース、LDAP、または外部 IDサービスからユーザーをロードし、PasswordEncoderを使用してパスワードダイジェストを保存する必要があります。

デフォルトのランディングページの形成プロセス

デフォルトの構成では、起動およびアクセス手順を次のように要約できます。

Spring Boot 启动
→ 加载 Spring Security 自动配置
→ 创建安全过滤器链
→ 请求被过滤器链拦截
→ 发现请求尚未认证
→ 进入登录流程或返回认证响应
Securityがコントローラを使用してリクエストを傍受しない理由

Spring SecurityのサーブレットサポートはFilterメカニズム上に構築された。フィルタはDispatcherServletとコントローラの前にあり、要求がビジネスレイヤに入る前に認証、認可、セキュリティコンテキスト管理を完了できます。

一般的な順序は次のように簡略化できます。

Servlet Filter → DispatcherServlet → HandlerInterceptor → Controller

Spring Securityの基本原則

学習の目標

この章を完了すると、次のことが可能になります。

  1. Spring Securityのリクエストフロー全体を理解する。
  2. SecurityFilterChainの仕組みを理解する。
  3. Authenticationの主な用途を2つ理解する。
  4. AuthenticationManagerAuthenticationProviderの関係を把握する。
  5. SecurityContextSecurityContextHolderの機能を理解する。
  6. ユーザー名パスワードを解析する主な実行手顺。

フィルタベースの安全処理

従来のSpring MVCアプリケーションでは、リクエストフローは以下のように単純化できます。

客户端请求
→ Servlet Filter
→ DispatcherServlet
→ HandlerInterceptor
→ Controller
→ Service
→ DAO
→ Database

Spring Securityはサーブレットセキュリティポータルとしてフィルタを選択し、ビジネスコードを実行する前に認証と承認を行うことができます。

たとえば、ユーザがGET /api/user/delete/1を要求すると、セキュリティフィルタチェーンはユーザが認証されているかどうか、削除権限があるかどうかを判断します。検証に失敗した場合、要求はターゲットコントローラに入りません。

SecurityFilterChain

SecurityFilterChainは、セキュリティタスクを処理するためのフィルタのセットを表します。アプリケーションは複数のセキュリティフィルタチェーンを宣言でき、それぞれが異なるリクエスト範囲を担当します。

image-004
image-004

FilterChainProxyとFilterChainProxyの違い

Spring Securityのサーブレットリクエスト転送関係は次のように要約できます。

DelegatingFilterProxy
→ FilterChainProxy
→ 匹配到的 SecurityFilterChain
→ 链内的 Security Filter
image-005
image-005
  1. DelegatingFilterProxyはサーブレットコンテナに登録され、Springコンテナが管理するFilter Beanにフィルタリング作業を委任します。
  2. FilterChainProxyはSpring Securityのコアポータルであり、現在のリクエストに基づいて適切なSecurityFilterChainにマッチします。
  3. 選択されたSecurityFilterChainは、その中の安全フィルタを順番に実行します。

フィルタの具体的な構成は構成によって異なります。一般的なフィルターは:

  • SecurityContextHolderFilterセキュリティコンテキストをロードして管理します。
  • CfFilter CSRFが有効なにCSRFを検证します。
  • UsernamePasswordAuthenticationFilterフォームログインが有効になっているときにユーザー名パスワードログイン要求を処理します。
  • BearerTokenAuthenticationFilter OAuth 2.0リソースサーバの構成時にBearer Tokenを処理します。
  • AnonymousAuthenticationFilter:匿名要求に匿名 Authenticationを提供する.
  • ExceptionTranslationFilter:認証と認可例外を対応する処理フローに変換する.
  • AuthorizationFilter:リクエストレベルの認可判断を実行します。

一部のレガシースキーマ図にはFilterSecurityInterceptorが表示されます。Spring Security 6の一般的な構成では、認可要求は通常AuthorizationFilterによって行われます。実際のフィルタのリストは、プロジェクトの開始ログまたはデバッグ出力に基づいています。

認証認証モデル

image-006
image-006

Authenticationの役割

Authenticationには主に2つの用途がある。

  1. 認証対象資格情報としてAuthenticationManagerに提出します。
  2. 現在認証されている主体を表し、SecurityContextに保存します。

インタフェースで一般的に使用される方法は次のとおりです。

public interface Authentication extends Principal, Serializable {
    Object getPrincipal();

    Object getCredentials();

    Collection<? extends GrantedAuthority> getAuthorities();

    boolean isAuthenticated();
}
方法作用
getPrincipal本体を返します。ユーザー名パスワード認証に成功した後は通常UserDetails
getCredentials証明書(通常はパスワード)を返します。認証が成功するとクリアされることがあります。
getAuthoritiesロール、権限、またはスコープのコレクションを返す
isAuthenticated現在のオブジェクトが認証済みかどうかを示します

認定前後の状態

ログイン前に作成されたUsernamePasswordAuthenticationTokenには、通常、ユーザー名とパスワードのみが含まれており、認証されていません。

UsernamePasswordAuthenticationToken
├── principal: admin
├── credentials: 123456
├── authorities: []
└── authenticated: false

認証に成功した後に返される新しいオブジェクトには、通常、ユーザーの詳細と権限が含まれ、認証済みの状態になります。

UsernamePasswordAuthenticationToken
├── principal: UserDetails
├── credentials: null
├── authorities: [ROLE_ADMIN, permission:query]
└── authenticated: true

安全でない方法で未検証オブジェクトを認証済みとして直接マークしないでください。

AuthenticationManagerとAuthenticationProvider

AuthenticationManagerは、統一認証ポータルを定義します。

Authentication authenticate(Authentication authentication)
        throws AuthenticationException;

ProviderManagerAuthenticationManagerの最も一般的な実装である。現在のAuthenticationタイプをサポートするプロバイダが認証を実行する複数のAuthenticationProviderを順番に尋ねます。

一般的なユーザー名パスワード認証手順は次のとおりです。

Authentication
→ AuthenticationManager
→ ProviderManager
→ DaoAuthenticationProvider
→ UserDetailsService
→ PasswordEncoder

認証方法によって異なるプロバイダが使用できます。例えば:

  • DaoAuthenticationProviderは、データベースユーザー名パスワード認証を処理します。
  • OAuth 2.0リソースサーバーのJwtAuthenticationProviderはJWTベアートークンを処理します。
  • カスタムAuthenticationProviderは、SMS 認証コードなどのビジネス資格情報を処理することができる。

UserDetailsService

Spring Securityはユーザがどこにいるかを知らないため、UserDetailsServiceでユーザクエリプロセスを抽象化します。

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException;
}

ユーザー情報は、My SQL、Redis、LDAP、または他のユーザーセンターから取得できます。

以下は、簡略化された例です。

@Service
@RequiredArgsConstructor
public class UserService implements UserDetailsService {

    private final UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userMapper.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("账号不存在");
        }

        return org.springframework.security.core.userdetails.User
                .withUsername(user.getUsername())
                .password(user.getPassword())
                .roles("USER")
                .build();
    }
}

SecurityContext Holderとセキュリティコンテキスト

SecurityContextは現在の認証オブジェクトを保持し、SecurityContextHolderは現在のセキュリティコンテキストへのアクセス·ポータルを提供する。

Authentication authentication = SecurityContextHolder
        .getContext()
        .getAuthentication();

サーブレットアプリケーションでは、SecurityContextHolderはデフォルトでThreadLocalポリシーを使用し、同じリクエストスレッド内のコードが現在のユーザーを読み取ることができます。

ThreadLocalはデフォルトのポリシーに過ぎず、1つのリクエストがすべての場合に常に同じスレッドを使用するという意味ではありません。非同期タスク、スレッドプール、リアクティブプログラミングには、適切なコンテキスト伝播メカニズムが必要です。

+++セキュリティコンテキストがデフォルトでThreadLocalを使用する理由は?

デフォルトのサーブレット要求は、通常、スレッドで処理されます。ThreadLocalを使用すると、同じスレッド内のコードが現在の認証情報を直接取得し、パラメータのレイヤー渡しを回避し、異なる要求スレッドのセキュリティコンテキストを分離できます。Spring Securityはリクエスト後にコンテキストをクリーンアップし、スレッドの再利用によるアイデンティティ漏洩を防ぎます。

+++

ユーザー名パスワードログイン·プロセス

フォームのログインを有効にする場合の一般的なプロセスは次のとおりです。

POST /login
→ UsernamePasswordAuthenticationFilter
→ 创建未认证 UsernamePasswordAuthenticationToken
→ AuthenticationManager
→ ProviderManager
→ DaoAuthenticationProvider
→ UserDetailsService 查询用户
→ PasswordEncoder 校验密码
→ 返回已认证 Authentication
→ 保存到 SecurityContext
→ 执行认证成功处理

このレッスンでは、カスタムLoginControllerを使用してAuthenticationManagerを呼び出します。この場合、ログイン要求はUsernamePasswordAuthenticationFilterを通過せず、コントローラはJWTを生成します。後続の要求はカスタムJWTフィルタによってトークンを解決し、現在の要求のSecurityContextを確立します。

本章の概要

アセンブリ作用
SecurityFilterChain要求に必要なセキュリティフィルタの定義
FilterChainProxy安全フィルタチェーンのマッチングと実行
Authentication認証要求または現在の認証主体を示す
AuthenticationManager統一認証ポータルを提供
ProviderManager複数のAuthenticationProvider
AuthenticationProvider具体的な認証ロジックの実行
UserDetailsServiceユーザー情報のロード
PasswordEncoder暗号化と検証暗号
SecurityContext現在の認証対象を保存
SecurityContextHolder現在のセキュリティコンテキストへのアクセス

+++AuthenticationとUser Detailsの違いは何ですか?

UserDetailsは、ユーザデータソースからロードされたアカウント情報(ユーザ名、パスワードダイジェスト、アカウントステータス、権限など)を記述する。

Authenticationは、認証されるべきクレデンシャルを表すことも、認証された現在の主体を表すこともできる。認証が成功すると、principalは通常UserDetailsインスタンスになります。

+++

フロント·エンドの分離認証

image-007
image-007

この章では、次のシナリオを採用します。

用户名密码登录
→ AuthenticationManager 校验
→ 服务端签发 JWT
→ 客户端使用 Authorization 请求头携带 JWT
→ JWT 过滤器验签并建立 SecurityContext
→ Spring Security 执行访问授权

データベーステーブルの構造

次の例では、ユーザー、ロール、権限、およびメニューのを保持します。ロールテーブルには、ROLE_ADMINROLE_USERなどのパーミッションIDを生成するための安定した英語role_keyが追加されます。

DROP TABLE IF EXISTS `t_menu_role`;
DROP TABLE IF EXISTS `t_role_permission`;
DROP TABLE IF EXISTS `t_menu`;
DROP TABLE IF EXISTS `t_permission`;
DROP TABLE IF EXISTS `t_user`;
DROP TABLE IF EXISTS `t_role`;

CREATE TABLE `t_menu` (
    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
    `name` VARCHAR(255) DEFAULT NULL COMMENT '菜单名称',
    `icon` VARCHAR(255) DEFAULT NULL COMMENT '菜单图标',
    `url` VARCHAR(255) DEFAULT NULL COMMENT '菜单 URL',
    `pid` BIGINT DEFAULT NULL COMMENT '父菜单 ID',
    `remark` VARCHAR(255) DEFAULT NULL COMMENT '备注',
    `level` INT DEFAULT NULL COMMENT '菜单层级',
    `is_link` TINYINT DEFAULT 0 COMMENT '是否可跳转',
    PRIMARY KEY (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE `t_role` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `role_name` VARCHAR(255) NOT NULL,
    `role_key` VARCHAR(64) NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_role_key` (`role_key`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE `t_permission` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `permission_name` VARCHAR(255) NOT NULL,
    `permission_key` VARCHAR(255) NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_permission_key` (`permission_key`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE `t_user` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `username` VARCHAR(255) NOT NULL,
    `password` VARCHAR(255) NOT NULL,
    `role_id` BIGINT NOT NULL,
    `nickname` VARCHAR(255) DEFAULT NULL,
    `email` VARCHAR(255) DEFAULT NULL,
    `phone` VARCHAR(255) DEFAULT NULL,
    `freeze` TINYINT DEFAULT 0,
    `dept_id` BIGINT DEFAULT NULL,
    `remark` VARCHAR(255) DEFAULT NULL,
    `insert_time` DATETIME DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_username` (`username`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE `t_role_permission` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `role_id` BIGINT NOT NULL,
    `permission_id` BIGINT NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_role_permission` (`role_id`, `permission_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE `t_menu_role` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `menu_id` BIGINT NOT NULL,
    `role_id` BIGINT NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_menu_role` (`menu_id`, `role_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

START TRANSACTION;

INSERT INTO `t_role` (`id`, `role_name`, `role_key`) VALUES
    (1, '系统管理员', 'ADMIN'),
    (2, '普通用户', 'USER');

INSERT INTO `t_permission` (`id`, `permission_name`, `permission_key`) VALUES
    (1, '查询', 'permission:query'),
    (2, '新增', 'permission:insert'),
    (3, '修改', 'permission:update'),
    (4, '删除', 'permission:delete');

INSERT INTO `t_role_permission` (`id`, `role_id`, `permission_id`) VALUES
    (50, 1, 1),
    (51, 1, 2),
    (52, 1, 3),
    (53, 1, 4),
    (61, 2, 1);

INSERT INTO `t_menu` (`id`, `name`, `icon`, `url`, `pid`, `remark`, `level`, `is_link`) VALUES
    (1, '系统设置', 'layui-icon-fire', NULL, -1, '', 1, 0),
    (2, '分类管理', 'layui-icon-name', 'category/list', 1, '', 2, 1),
    (3, '商品管理', 'layui-icon-service', NULL, -1, '', 1, 0),
    (4, '商品列表', 'layui-icon-rate', 'asset-info/list', 3, '', 2, 1);

INSERT INTO `t_menu_role` (`id`, `menu_id`, `role_id`) VALUES
    (1, 1, 1),
    (2, 2, 1),
    (3, 3, 1),
    (4, 4, 1),
    (5, 3, 2),
    (6, 4, 2);

INSERT INTO `t_user`
    (`id`, `username`, `password`, `role_id`, `nickname`, `insert_time`)
VALUES
    (1, 'admin',
     '$2a$10$t9BSP6hInmZm5RJocXVMdOLXzVXh4wgiBaYrM6iUslsrb.5z.eYce',
     1, '超级管理员', '2021-05-26 14:41:06'),
    (2, 'normal',
     '$2a$10$Mv1ruD0gHy9Uq73SbfH80ep1McuJNZiJCjM3BpxAJVr9pt34iwWlS',
     2, '普通用户', NULL);

COMMIT;

バックエンドの依存性

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

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

    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
        <version>3.5.6</version>
    </dependency>

    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>

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

同じプロジェクト内のJJWTモジュールは、同じバージョンを使用する必要があります。

フロントエンドリクエストインターセプタ

ログインが成功すると、フロントエンドはアクセストークンを保存し、後続のリクエストのAuthorizationリクエストヘッダーにBearer Tokenを運びます。

import axios from 'axios'

const service = axios.create({
  baseURL: 'http://localhost:8080',
  timeout: 5000
})

service.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  error => Promise.reject(error)
)

service.interceptors.response.use(
  response => response.data,
  error => {
    const status = error.response?.status

    if (status === 401) {
      localStorage.removeItem('token')
      window.location.href = '/login'
    }

    return Promise.reject(error)
  }
)

export default service

HTTPステータスコードは、サービスレスポンスボディ内のサービスコードとは別に扱われます。

  • 401 Unauthorizedは、認証されていない、無効なToken、またはTokenが期限切れであることを示します。
  • 403 Forbiddenは認証済みだがアクセス権がないことを示している。

Security Coreの構成

import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.nio.charset.StandardCharsets;
import java.util.List;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private static final String[] WHITE_LIST = {
            "/login",
            "/doc.html",
            "/webjars/**",
            "/favicon.ico",
            "/druid/**",
            "/public/**"
    };

    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http,
            JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(Customizer.withDefaults())
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(exceptions -> exceptions
                        .authenticationEntryPoint((request, response, exception) -> {
                            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
                            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                            response.getWriter().write(
                                    "{\"code\":401,\"msg\":\"未登录或令牌无效\"}"
                            );
                        })
                        .accessDeniedHandler((request, response, exception) -> {
                            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
                            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                            response.getWriter().write(
                                    "{\"code\":403,\"msg\":\"没有访问权限\"}"
                            );
                        }))
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                        .requestMatchers(WHITE_LIST).permitAll()
                        .anyRequest().authenticated())
                .addFilterBefore(
                        jwtAuthenticationFilter,
                        UsernamePasswordAuthenticationFilter.class
                );

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:5173"));
        configuration.setAllowedMethods(List.of(
                "GET", "POST", "PUT", "DELETE", "OPTIONS"
        ));
        configuration.setAllowedHeaders(List.of(
                HttpHeaders.AUTHORIZATION,
                HttpHeaders.CONTENT_TYPE
        ));
        configuration.setExposedHeaders(List.of(HttpHeaders.AUTHORIZATION));
        configuration.setAllowCredentials(false);

        UrlBasedCorsConfigurationSource source =
                new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }
}

この例のトークンはAuthorizationリクエストヘッダーを介してのみ配信され、サーバーはブラウザのCookieに依存しないため、CSRFをオフにします。クッキーが自動的に資格情報を運ぶ場合は、適切なCSRF 保護を再評価して有効にする必要があります。

@EnableWebSecurityはSpring SecurityのサーブレットWebセキュリティ構成をインポートするために使用します。実際のリクエストルールはSecurityFilterChain Beanによって定義され、Spring Boot Starterが関連する自動構成と基本的な統合を担当します。

本番環境でのCORSでは、ソースは実際のフロントエンドドメイン名として設定する必要があり、任意のソースを無条件に解放する必要はありません。

実体とマッパーの生成

MyBatis Plusリバースエンジニアリングを使用して、UserRolePermissionMenuおよびそれらのマッパーを生成できます。生成後、フィールドタイプ、テーブル関連クエリ、NULL 処理をチェックする必要があり、リバースエンジニアリング結果を完全なビジネスコードとはみなされません。

UserDetailsの実装

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;
import java.util.stream.Stream;

public class UserItem implements UserDetails {

    private final User user;
    private final List<String> permissions;
    private final List<GrantedAuthority> authorities;

    public UserItem(User user, String roleKey, List<String> permissions) {
        this.user = user;
        this.permissions = List.copyOf(permissions);
        this.authorities = Stream.concat(
                        Stream.of(new SimpleGrantedAuthority("ROLE_" + roleKey)),
                        permissions.stream().map(SimpleGrantedAuthority::new)
                )
                .map(GrantedAuthority.class::cast)
                .toList();
    }

    public User getUser() {
        return user;
    }

    public List<String> getPermissions() {
        return permissions;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return user.getFreeze() == null || user.getFreeze() == 0;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

hasRole('ADMIN')は自動的にROLE_ADMINをチェックするため、データベース内の安定したロールIDはADMINとして保存され、パーミッションを構築する際にROLE_プレフィックスが追加されます。

User DetailsServiceの実装

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserMapper userMapper;
    private final RoleMapper roleMapper;
    private final PermissionMapper permissionMapper;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userMapper.selectOne(
                new LambdaQueryWrapper<User>()
                        .eq(User::getUsername, username)
                        .last("LIMIT 1")
        );

        if (user == null) {
            throw new UsernameNotFoundException("账号不存在");
        }

        Role role = roleMapper.selectById(user.getRoleId());
        if (role == null) {
            throw new UsernameNotFoundException("账号未配置有效角色");
        }

        List<String> permissions = permissionMapper
                .selectPermissionListByRoleId(user.getRoleId())
                .stream()
                .map(Permission::getPermissionKey)
                .toList();

        return new UserItem(user, role.getRoleKey(), permissions);
    }
}

BCrypt 暗号コード

BCryptPasswordEncoderはランダムソルトを用いた適応的一方向ハッシュアルゴリズムを使用する。同じ平文を符号化するたびに結果は異なりますが、matches()で検証することができます。

@Test
void shouldEncodeAndMatchPassword() {
    PasswordEncoder encoder = new BCryptPasswordEncoder();

    String encodedPassword = encoder.encode("123456");
    boolean matched = encoder.matches("123456", encodedPassword);

    System.out.println(encodedPassword);
    System.out.println(matched);
}

暗号ダイジェストは平文に“復号”できません。再度エンコードして文字列を比較するのではなく、ログイン時にmatches(rawPassword, encodedPassword)チェックを使用してください。

統合レスポンスオブジェクト

public record Result<T>(int code, T data, String msg, long count) {

    public static <T> Result<T> ok(T data, String msg) {
        return new Result<>(200, data, msg, 0);
    }

    public static Result<Void> ok(String msg) {
        return new Result<>(200, null, msg, 0);
    }

    public static Result<Void> fail(int code, String msg) {
        return new Result<>(code, null, msg, 0);
    }

    public static Result<Void> fail(String msg) {
        return fail(500, msg);
    }
}

サービスレスポンスコードはHTTPステータスコードと明確なセマンティクスを維持する必要があります。実際のプロジェクトでは、ResponseEntityまたはグローバル例外ハンドラを使用してHTTPステータスを統一的に設定できます。

Jフィルタ

カスタムログインインターフェイスはトークンの発行のみを担当します。その後の各保護されたリクエストはトークンを解析し、認証結果を現在のリクエストのSecurityContextに配置する必要があります。

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

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

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final String BEARER_PREFIX = "Bearer ";

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = authorization.substring(BEARER_PREFIX.length()).trim();

        try {
            Claims claims = jwtUtil.parseToken(token);

            if (SecurityContextHolder.getContext().getAuthentication() == null) {
                List<?> values = claims.get("permissions", List.class);
                List<GrantedAuthority> authorities = values == null
                        ? List.of()
                        : values.stream()
                                .filter(String.class::isInstance)
                                .map(String.class::cast)
                                .map(SimpleGrantedAuthority::new)
                                .map(GrantedAuthority.class::cast)
                                .toList();

                UsernamePasswordAuthenticationToken authentication =
                        UsernamePasswordAuthenticationToken.authenticated(
                                claims.getSubject(),
                                null,
                                authorities
                        );

                SecurityContext context =
                        SecurityContextHolder.createEmptyContext();
                context.setAuthentication(authentication);
                SecurityContextHolder.setContext(context);
            }

            filterChain.doFilter(request, response);
        } catch (JwtException | IllegalArgumentException exception) {
            SecurityContextHolder.clearContext();
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(
                    "{\"code\":401,\"msg\":\"令牌无效或已过期\"}"
            );
        }
    }
}

この例では、JWTのパーミッションを直接使用します。利点は、毎回データベースをクエリする必要がないことです。欠点は、パーミッションの変更が古いトークンに直ちに影響しないことです。プロダクションシステムは、短い期間のトークン、トークンバージョン番号、ブラックリスト、またはリクエストごとに重要な権限を再照会できます。

ログイン·インタフェースのカスタマイズ

import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequiredArgsConstructor
public class LoginController {

    private final AuthenticationManager authenticationManager;
    private final MenuService menuService;
    private final JwtUtil jwtUtil;

    @PostMapping("/login")
    public Result<Map<String, Object>> login(
            @RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(
                        request.username(),
                        request.password()
                )
        );

        UserItem userItem = (UserItem) authentication.getPrincipal();
        User user = userItem.getUser();

        List<String> authorities = authentication.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .toList();

        String token = jwtUtil.createToken(
                user.getId(),
                user.getUsername(),
                user.getRoleId(),
                authorities
        );

        List<Menu> menuList = menuService.listByRoleId(user.getRoleId());

        Map<String, Object> data = new HashMap<>();
        data.put("token", token);
        data.put("user", user);
        data.put("menuList", menuList);
        data.put("authorities", authorities);

        return Result.ok(data, "登录成功");
    }

    public record LoginRequest(String username, String password) {
    }
}

認証例外はグローバル例外ハンドラに渡されます。

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.LockedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BadCredentialsException.class)
    public ResponseEntity<Result<Void>> handleBadCredentials() {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Result.fail(401, "用户名或密码错误"));
    }

    @ExceptionHandler(LockedException.class)
    public ResponseEntity<Result<Void>> handleLocked() {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(Result.fail(403, "账号已被冻结"));
    }
}

パスワードやパスワードダイジェストなどの機密フィールドをログイン応答に直接入力しないでください。フロントエンドが実際に必要とするフィールドのみを返す専用のユーザー応答オブジェクトを作成できます。

インターフェースのテスト

ログインが成功すると、トークン、ユーザープロフィール、メニュー、および権限コレクションがレスポンスに返されます。後続のリクエストはリクエストヘッダーで運ぶ必要があります。

Authorization: Bearer <token>
image-008
image-008

実戦の制御

メソッドレベルの認可を開く

Spring Security 6は@EnableMethodSecurityを使用してメソッドレベルのライセンスを有効にする。

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@SpringBootApplication
@EnableMethodSecurity
public class SecurityApplication {

    public static void main(String[] args) {
        SpringApplication.run(SecurityApplication.class, args);
    }
}

spring-boot-starter-securityはデフォルトでメソッドレベルの認可を有効にしないため、@EnableMethodSecurityを明示的に追加する必要があります。

略称は@PreAuthorize

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PermissionController {

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin")
    public String admin() {
        return "管理员接口访问成功";
    }

    @PreAuthorize("hasAuthority('permission:query')")
    @GetMapping("/user/list")
    public String userList() {
        return "用户列表接口访问成功";
    }

    @PreAuthorize("hasAnyRole('ADMIN', 'USER')")
    @GetMapping("/common")
    public String common() {
        return "通用接口访问成功";
    }
}

よく使われる表現は次の通り。

意味
hasRole 'ADMIN'ROLE_があるかどうか
hasAnyRole 'ADMIN''USER'いずれかの指定ロールを持つかどうか
hasAuthority 'permission query'指定された権限があるかどうか
hasAnyAuthority 'a''b'指定された権限があるかどうか
isAuthenticated現在のユーザーが認証済みかどうか
面接の質問@PreAuthorizeの実行原理とは何ですか

メソッドセキュリティを有効にすると、Spring SecurityはSpring AOPに基づいて保護されたSpring Beanのプロキシを作成します。メソッド呼び出しの前に、AuthorizationManagerBeforeMethodInterceptor@PreAuthorizeのSpEL 式を読み取り、現在のAuthenticationと関連して認可判断を行います。

ターゲットメソッドは検証に合格するまで呼び出されません。検証に失敗するとAccessDeniedExceptionがスローされます。HTTPリクエストチェーンでは、この例外は通常403 Forbiddenレスポンスに変換されます。

主な考慮事項

  1. 認証の成功は、すべての権限を持っていることを意味するものではありません。認証と認可は、個別に設定する必要があります。
  2. 401は認証されていないことを示し、403は認証されているが権限が不十分であることを示します。
  3. JWTフィルタは、認証済みAuthenticationを作成する前に、署名と有効期限を検証する必要があります。
  4. クライアントから直接コミットされたロールや権限を信頼しないでください。
  5. パスワード、パスワードダイジェスト、キーをトークンまたはレスポンスボディに返さないでください。
  6. CORS、CSRF、XSSは別々のセキュリティ問題であり、互いに置き換えることはできません。
  7. 本番システムはHTTPSを使用し、トークンの有効期限、リフレッシュ、取り消しメカニズムを合理的に設定する必要があります。
  8. メソッドレベルの認可は、フロントエンドボタンの表示だけではなく、サービスレベルの重要な操作を保護するのに適しています。

気に入ったならばコメントを残してくださいね~

... ページビュー
© 2026 跨越星轨的客 @Hoshiumi
Powered by theme astro-koharu · Inspired by Shoka