2025年12月13日土曜日

ログイン成功!でも画面が変わらない問題の解決

8:01

完璧です!エラーの原因が特定できました。

問題の核心

DBに保存されているロールは "ROLE_USER" という形式ですが、CustomUserDetailsService で .roles() メソッドを使うと、Spring Securityが自動的に "ROLE_" を追加してしまい、結果的に "ROLE_ROLE_USER" という不正な形式になっています。


修正方法

CustomUserDetailsService.java を以下のように修正してください:

package com.example.blog.service;

import com.example.blog.entity.User;
import com.example.blog.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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;
import java.util.stream.Collectors;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

        // ✅ 修正:DBのロールはすでに "ROLE_" プレフィックス付きなので
        // .roles() ではなく .authorities() を使用
        List authorities = user.getRoles().stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        return org.springframework.security.core.userdetails.User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .authorities(authorities)  // ← .roles() ではなく .authorities() を使用
                .accountLocked(!user.isAccountNonLocked())
                .disabled(!user.isEnabled())
                .build();
    }
}

重要なポイント

変更前(エラーが出る)

java
.roles(rolesArray)  // ❌ "ROLE_USER" → "ROLE_ROLE_USER" になってしまう

変更後(正しい)

java
.authorities(authorities)  // ✅ "ROLE_USER" のまま

確認手順

  1. ファイルを保存
  2. アプリを再起動
bash
   ./gradlew bootRun --args='--spring.profiles.active=dev'
  1. ログインテスト
    • 既存のサンプルユーザー:
      • Username: user1 / Password: password1
      • Username: user2 / Password: password2
    • 新規登録したユーザー: 登録時に入力した情報でログイン

補足:DBのロール形式について

V5__Insert_sample_data.sql を見ると、ロールは すでに "ROLE_USER""ROLE_ADMIN" の形式 で保存されています:

sql
INSERT INTO user_roles (user_id, role)
VALUES 
    ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ROLE_USER'),
    ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22', 'ROLE_ADMIN');
```

そのため、`.authorities()` を使ってそのまま渡すのが正解です。

---

## 動作確認

修正後、以下のログが表示されなくなり、正常にログインできるはずです:
```
❌ 消えるエラー:
ROLE_USER cannot start with ROLE_ (it is automatically added)

ログイン成功後は、セッションが確立され、認証済みユーザーとしてアプリを利用できます!

 

hiroppy123@fedora:~/springboot-gradle-blog-main$ ./gradlew bootRun --args='--spring.profiles.active=dev' > Task :bootRun . ____ _ __ _ _ /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____|

pasted

V5__Insert_sample_data.sql

30行

sql

V6__Alter_posts_id_to_uuid.sql

22行

sql

Username: user1 / Password: password1とhiroppy123、hiroppy123パスワードで登録し、ログインしようとして、失敗しました。どうしたら良いですか?サンプルユーザー (user1, user2) のログイン失敗について ログから、データベースに挿入されたサンプルユーザーのパスワードハッシュはBCrypt形式で保存されています。サンプルデータのSQLスクリプト (V5__Insert_sample_data.sql) を見ると、user1とuser2のパスワードはどちらも "password" です (ハッシュ値から逆引きして確認可能で、多くのSpring Bootチュートリアルでこの値が使われています)。あなたが試した "password1" は一致しません。これが失敗の原因です。 * user1: * Username: user1 * Password: password (ハッシュ: $2a$10$CE9zBhxW6rA3GsMsr2E9K.MzvtQi5wwZDYNkFN0Vok8hNZoZuAJ52) * user2: * Username: user2 * Password: password (ハッシュ: $2a$10$PCr0UPRyFIrh7DLLh1X8Q.D0KsWHW9hdhQ94cN.QjJbSlsjtaiStW) これでログインを試してみてください。成功するはずです。もし失敗するなら、データベースのハッシュが変更されていないか確認してください (H2コンソールで /h2-console にアクセス可能)。 登録ユーザー (hiroppy123) のログイン失敗について 登録時 (POST /register) にユーザーを作成した後、ログイン試行で2回失敗 (BadCredentialsException: パスワードが一致しない) していますが、ログの最後の試行 (08:08:36 POST /login) では認証に成功し、リダイレクト (Redirecting to /) されています。つまり、最後の試行で正しいパスワードを入力したようです。 失敗の原因は、おそらく入力ミスです: * 登録時のパスワード: クエリから "hiroppy123" (ユーザー名とパスワードが同じ?) または "hiroppy123" をパスワードとして設定したと思われます。 * ログイン時: タイプミス (大文字/小文字の区別、スペースの混入、全角/半角) や、登録時のパスワードを忘れた可能性。 解決策 1. パスワードの確認/リセット: * アプリケーションにパスワードリセット機能がない場合、データベースを直接確認/修正してください。 * H2コンソール (/h2-console) または PostgreSQLクライアントで users テーブルを見て、hiroppy123 のパスワードハッシュを確認。新しいハッシュを生成して更新 (BCryptで新しいパスワードをハッシュ化)。 * 例: Spring Bootのコードで BCryptPasswordEncoder を使ってハッシュ生成 (開発環境でテスト)。 2. 入力ミスを防ぐ: * パスワードは大文字/小文字を区別します。 "hiroppy123" を正確に入力。 * もしパスワードが "hiroppy123" なら、ログイン画面でそれを試す。 3. セキュリティの注意: * 実際の運用では、パスワードは最低8文字以上、複雑にする (数字、記号含む)。 * ログイン失敗回数を制限する機能 (Spring Securityのデフォルトで有効) を有効にしておくと良い。 ログからアプリは正常に動作しているので、上記の修正でログインできるはずです。追加のログやエラーがあれば、共有してください。       ーーーーー      hiroppy123でログイン出来たかもしれませんが、リダイレクト先がHOMEで、リンクもLoginのまま(Logoutにならなかった。)    記事のページに移動出来る様にしたり、    その他修正の方法を、雑学や業界話を交えながら解説してください。

8:28

🎉 ログイン成功!でも画面が変わらない問題の解決

素晴らしい! ログ 08:08:36 を見ると、認証は完全に成功しています:

Authenticated user
Set SecurityContextHolder to UsernamePasswordAuthenticationToken
[Username=hiroppy123, ..., Granted Authorities=[ROLE_USER]]
Redirecting to /

しかし、画面上はまだ「Login」リンクが表示されているというのは、よくある**「認証は通ったけどUIが追いついていない」**問題です。


🔍 問題の原因:Thymeleafがセキュリティコンテキストを認識していない

業界あるある話

これはSpring Security + Thymeleafでよく起こる問題で、特に初学者が必ず一度は踏む地雷です😅

なぜ起こるか?

  • Spring SecurityのSecurityContext(「誰がログインしているか」の情報)はサーバー側で保持されている
  • しかし、Thymeleafテンプレート側でその情報を読み取る設定をしていないと、画面は「誰もログインしていない」と判断してしまう

業界では?

  • 大規模プロジェクトでは、ReactやVueなどのSPA(Single Page Application)を使い、JWTトークンで認証状態を管理することが多い
  • でも、Spring Boot + Thymeleafの組み合わせは中小規模のエンタープライズでは定番!理由は「サーバーサイドレンダリング(SSR)で完結するのでセキュリティが強固」だから

✅ 解決方法1:Thymeleaf Security Extrasを有効化

1. build.gradle に依存関係を追加

gradle
dependencies {
    // 既存の依存関係...
    
    // ✅ Thymeleaf Security統合
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.2.RELEASE'
}

ポイント:

  • Spring Security 6.x系を使っている場合は springsecurity6 を指定
  • これで sec:authorize などのThymeleaf属性が使えるようになる

2. home.html(または共通ヘッダー)を修正

現在の home.html のナビゲーション部分を以下のように修正:

html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
    <title>Home</title>
    <link rel="shortcut icon" type="image/x-icon" th:href="@{/images/favicon.png}">
    <style>
        /* 既存のスタイル */
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: Arial, sans-serif;
            background-color: #f4f4f4;
            height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            position: relative;
        }

        /* ✅ ナビゲーションのスタイル改善 */
        header {
            position: absolute;
            top: 10px;
            right: 10px;
            display: flex;
            align-items: center;
            gap: 15px;
        }

        .nav-links {
            list-style-type: none;
            display: flex;
            gap: 15px;
        }

        .nav-links li a {
            color: navy;
            text-decoration: none;
            font-size: 14px;
            padding: 5px 10px;
            border-radius: 4px;
            transition: background-color 0.3s;
        }

        .nav-links li a:hover {
            background-color: rgba(0, 0, 139, 0.1);
            text-decoration: underline;
            color: red;
        }

        /* ✅ ログアウトボタンのスタイル */
        .logout-form {
            margin: 0;
        }

        .logout-btn {
            background-color: #dc3545;
            color: white;
            border: none;
            padding: 5px 15px;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
            transition: background-color 0.3s;
        }

        .logout-btn:hover {
            background-color: #c82333;
        }

        /* ✅ ユーザー名表示 */
        .username-display {
            color: navy;
            font-weight: bold;
            font-size: 14px;
        }

        .logo-container {
            width: 100%;
            height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
        }

        .logo {
            width: 100%;
            height: 100%;
            object-fit: cover;
            min-width: 100%;
            min-height: 100%;
        }
    </style>
</head>
<body>
    <header>
        <!-- ✅ ログインしていないユーザー向け -->
        <nav sec:authorize="!isAuthenticated()">
            <ul class="nav-links">
                <li><a th:href="@{/login}">Login</a></li>
                <li><a th:href="@{/register}">Register</a></li>
                <li><a th:href="@{https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/}">Documentation</a></li>
            </ul>
        </nav>

        <!-- ✅ ログイン済みユーザー向け -->
        <nav sec:authorize="isAuthenticated()">
            <ul class="nav-links">
                <!-- ユーザー名表示 -->
                <li>
                    <span class="username-display">
                        Welcome, <span sec:authentication="name">User</span>!
                    </span>
                </li>
                <!-- 記事一覧へのリンク -->
                <li><a th:href="@{/posts}">Posts</a></li>
                <!-- 管理者のみ表示 -->
                <li sec:authorize="hasRole('ADMIN')">
                    <a th:href="@{/admin}">Admin</a>
                </li>
                <li><a th:href="@{https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/}">Documentation</a></li>
            </ul>
            
            <!-- ✅ ログアウトフォーム -->
            <form class="logout-form" th:action="@{/logout}" method="post">
                <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
                <button type="submit" class="logout-btn">Logout</button>
            </form>
        </nav>
    </header>

    <div class="logo-container">
        <img th:src="@{/images/logo.png}" alt="Blog Logo" class="logo">
    </div>
</body>
</html>

🎯 重要ポイントの解説

1. xmlns:sec の宣言

html
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

これで sec:authorize などのセキュリティ属性が使えるようになる。


2. 条件分岐の使い方

属性 説明
sec:authorize="!isAuthenticated()" 未ログインの時だけ表示 Login/Registerリンク
sec:authorize="isAuthenticated()" ログイン済みの時だけ表示 Logout/Postsリンク
sec:authorize="hasRole('ADMIN')" ADMIN権限を持つ時だけ表示 管理者メニュー
sec:authentication="name" ログイン中のユーザー名を表示 "Welcome, hiroppy123!"

3. ログアウトの実装

html
<form th:action="@{/logout}" method="post">
    <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
    <button type="submit">Logout</button>
</form>

なぜフォームなのか?

  • Spring SecurityのCSRF保護により、ログアウトはPOSTリクエストでないと受け付けない
  • <a href="/logout"> では動かない(CSRF tokenが送れないため)

✅ 解決方法2:記事一覧ページへのリンク追加

PostController.java の確認

以下のようなコントローラーがあるはず:

java
@Controller
public class PostController {
    
    @GetMapping("/posts")
    public String listPosts(Model model) {
        // 記事一覧をDBから取得
        List<Post> posts = postRepository.findAll();
        model.addAttribute("posts", posts);
        return "posts/list";  // posts/list.html を表示
    }
}

もしない場合は、新規作成してください。


🎨 業界的な豆知識

なぜThymeleafなのか?

フレームワーク 用途 長所 短所
Thymeleaf サーバーサイドレンダリング SEO強い、セキュリティ堅牢 リアルタイム更新が苦手
React/Vue SPA(フロントエンド) UXが滑らか、リアルタイム性◎ SEO対策が面倒、JWT管理必須
JSP レガシーシステム 枯れた技術 もう新規で使われない😅

Spring Boot + Thymeleafの組み合わせは:

  • 銀行、保険、公共機関などのエンタープライズ向けで人気
  • 理由:「セッション管理がサーバー側で完結するのでセキュリティが強固」

🚀 次のステップ

  1. 依存関係を追加 → ./gradlew clean build
  2. home.htmlを修正 → ログイン/ログアウトの切り替え実装
  3. 再起動 → ./gradlew bootRun
  4. ブラウザで確認:
    • ログイン前:「Login/Register」表示
    • ログイン後:「Welcome, hiroppy123! / Posts / Logout」表示

成功したら、次は記事一覧ページ(/posts)の実装に進みましょう!🎉


何か詰まったら、エラーログを貼り付けてください!

PostController.java

65行

java

他のコードとの整合性も含めてこれは大丈夫でしょうか?    問題があれば、修正方法を雑学や業界話を交えながら解説してください。

8:56

🚨 PostController.javaに重大な問題発見!

このコードには致命的な設計ミスが3つあります。業界では「これやったら即レビュー差し戻し」レベルの問題です😅


❌ 問題1:@RestController と @Controller の混在

java
@RestController  // ← これはJSON APIを返すアノテーション
@RequestMapping("/api/posts")
public class PostController {
    // ...
    
    @GetMapping("/posts")  // ← でもここでHTMLを返そうとしている
    public String listPosts(Model model) {
        return "posts/list";  // ← Thymeleafテンプレートを返そうとしている
    }
}

🔥 何が起こるか?

アノテーション 動作 返すもの
@RestController 全メソッドに@ResponseBodyが付く JSONやXML
@Controller Thymeleafなどのテンプレートエンジンを使う HTML

現状の問題:

  • @RestControllerが付いているので、return "posts/list";文字列としてブラウザに返される
  • つまり、画面には「posts/list」というテキストが表示されてしまう🤦‍♂️

🌍 業界あるある話:RESTful API vs MVC

2010年代前半

  • JavaScriptフレームワーク(Angular, React)が台頭
  • 「バックエンドはJSON API、フロントエンドはSPA」というフロント・バック分離が流行

現在(2024年)

  • マイクロサービス化が進み、「API用コントローラー」と「画面用コントローラー」を完全に分けるのが主流
  • Spring Bootでは:
    • /api/* → RESTful API(JSON)
    • / → MVC(HTML/Thymeleaf)

大規模プロジェクトでは:

  • PostApiController(JSON API用)
  • PostViewController(HTML画面用)
  • 完全に別クラスに分ける

❌ 問題2:パスの重複

java
@RequestMapping("/api/posts")  // ← ベースパス
public class PostController {
    
    @GetMapping  // ← /api/posts にマップされる
    public List<Post> getAllPosts() { ... }
    
    @GetMapping("/posts")  // ← /api/posts/posts にマップされる(おかしい!)
    public String listPosts(Model model) { ... }
}

現在のルーティング:

  • /api/posts → JSON返す(正しい)
  • /api/posts/posts → HTMLを返そうとする(意味不明!)

期待されるルーティング:

  • /api/posts → JSON API
  • /posts → HTML画面

❌ 問題3:Modelのインポート忘れ

java
import org.springframework.ui.Model;  // ← これが必要!

このインポートがないと、Model modelがコンパイルエラーになります。


✅ 正しい修正方法:2つのコントローラーに分離

1. PostApiController.java(新規作成)

これがRESTful API専用(JSON返す)

java
package com.example.blog.controller;

import com.example.blog.entity.Post;
import com.example.blog.repository.PostRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

/**
 * RESTful API専用コントローラー
 * フロントエンド(React, Vue等)やモバイルアプリから呼ばれる
 */
@RestController
@RequestMapping("/api/posts")
public class PostApiController {

    @Autowired
    private PostRepository postRepository;

    /**
     * 全記事取得(JSON形式)
     * GET /api/posts
     */
    @GetMapping
    public List<Post> getAllPosts() {
        return postRepository.findAll();
    }

    /**
     * 記事詳細取得(JSON形式)
     * GET /api/posts/{id}
     */
    @GetMapping("/{id}")
    public ResponseEntity<Post> getPostById(@PathVariable UUID id) {
        return postRepository.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    /**
     * 記事作成(JSON形式)
     * POST /api/posts
     */
    @PostMapping
    public ResponseEntity<Post> createPost(@RequestBody Post post) {
        Post savedPost = postRepository.save(post);
        return ResponseEntity.status(HttpStatus.CREATED).body(savedPost);
    }

    /**
     * 記事更新(JSON形式)
     * PUT /api/posts/{id}
     */
    @PutMapping("/{id}")
    public ResponseEntity<Post> updatePost(@PathVariable UUID id, @RequestBody Post postDetails) {
        return postRepository.findById(id)
                .map(post -> {
                    post.setTitle(postDetails.getTitle());
                    post.setContent(postDetails.getContent());
                    Post updatedPost = postRepository.save(post);
                    return ResponseEntity.ok(updatedPost);
                })
                .orElse(ResponseEntity.notFound().build());
    }

    /**
     * 記事削除(JSON形式)
     * DELETE /api/posts/{id}
     */
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deletePost(@PathVariable UUID id) {
        if (postRepository.existsById(id)) {
            postRepository.deleteById(id);
            return ResponseEntity.noContent().build();
        }
        return ResponseEntity.notFound().build();
    }
}

2. PostViewController.java(新規作成)

これがHTML画面専用(Thymeleaf使う)

java
package com.example.blog.controller;

import com.example.blog.entity.Post;
import com.example.blog.repository.PostRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.List;
import java.util.UUID;

/**
 * HTML画面表示専用コントローラー
 * Thymeleafテンプレートを返す
 */
@Controller
@RequestMapping("/posts")
public class PostViewController {

    @Autowired
    private PostRepository postRepository;

    /**
     * 記事一覧画面
     * GET /posts
     */
    @GetMapping
    public String listPosts(Model model) {
        List<Post> posts = postRepository.findAll();
        model.addAttribute("posts", posts);
        return "posts/list";  // templates/posts/list.html
    }

    /**
     * 記事詳細画面
     * GET /posts/{id}
     */
    @GetMapping("/{id}")
    public String viewPost(@PathVariable UUID id, Model model) {
        return postRepository.findById(id)
                .map(post -> {
                    model.addAttribute("post", post);
                    return "posts/detail";  // templates/posts/detail.html
                })
                .orElse("redirect:/posts");  // 存在しなければ一覧に戻る
    }

    /**
     * 記事作成フォーム表示
     * GET /posts/new
     */
    @GetMapping("/new")
    public String newPostForm(Model model) {
        model.addAttribute("post", new Post());
        return "posts/form";  // templates/posts/form.html
    }
}
```

---

### 3. 元の`PostController.java`を削除

**ファイルを削除**するか、名前を変更してバックアップとして保管してください。

---

## 🎯 修正後のルーティング表

| URL | メソッド | 返すもの | 用途 |
|-----|---------|---------|------|
| `/api/posts` | GET | JSON | API:全記事取得 |
| `/api/posts/{id}` | GET | JSON | API:記事詳細 |
| `/api/posts` | POST | JSON | API:記事作成 |
| `/posts` | GET | HTML | 画面:記事一覧 |
| `/posts/{id}` | GET | HTML | 画面:記事詳細 |
| `/posts/new` | GET | HTML | 画面:記事作成フォーム |

**これでAPIと画面が完全に分離され、綺麗な設計になります!**

---

## 📁 ファイル構成の推奨
```
src/main/java/com/example/blog/
├── controller/
│   ├── PostApiController.java      ← RESTful API
│   ├── PostViewController.java     ← HTML画面
│   ├── UserApiController.java      ← ユーザーAPI
│   └── AuthController.java         ← 認証画面
├── entity/
│   ├── Post.java
│   └── User.java
├── repository/
│   ├── PostRepository.java
│   └── UserRepository.java
└── service/
    ├── PostService.java
    └── UserService.java
```

---

## 🌍 業界的な豆知識:なぜ分けるのか?

### パターン1:モノリシック(全部一緒)
```
[ブラウザ][Spring Boot][DB]
            (HTML + JSON両方返す)
```
**問題点:**
- コードが混沌として保守性が低い
- モバイルアプリ対応が困難

---

### パターン2:フロント・バック分離(現代的)
```
[ブラウザ][Spring Boot(HTML)][DB]
[React SPA][Spring Boot(JSON API)][DB]
[モバイルアプリ][Spring Boot(JSON API)][DB]
```
**メリット:**
- API部分を複数のクライアントで共有できる
- フロントエンドとバックエンドの開発を並行できる

---

### パターン3:マイクロサービス(エンタープライズ)
```
[ブラウザ][API Gateway][Post Service(JSON)][User Service(JSON)][Auth Service(JSON)]

採用企業:

  • Netflix, Amazon, Uber(数百のマイクロサービス)
  • メルカリ、LINE(日本企業でも増加中)

🚀 次にやること

  1. 元のPostController.javaを削除
  2. 新しい2つのコントローラーを作成
  3. 再起動して動作確認
bash
   ./gradlew bootRun
  1. 確認ポイント
    • /posts にアクセス → HTML画面が表示される
    • /api/posts にアクセス → JSON配列が表示される

エラーが出たら、ログを貼り付けてください!🔧

0 件のコメント: