完璧です!エラーの原因が特定できました。
問題の核心
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();
}
}
重要なポイント
変更前(エラーが出る)
.roles(rolesArray) // ❌ "ROLE_USER" → "ROLE_ROLE_USER" になってしまう
変更後(正しい)
.authorities(authorities) // ✅ "ROLE_USER" のまま
確認手順
- ファイルを保存
- アプリを再起動
./gradlew bootRun --args='--spring.profiles.active=dev'
- ログインテスト
- 既存のサンプルユーザー:
- Username:
user1/ Password:password1 - Username:
user2/ Password:password2
- Username:
- 新規登録したユーザー: 登録時に入力した情報でログイン
- 既存のサンプルユーザー:
補足:DBのロール形式について
V5__Insert_sample_data.sql を見ると、ロールは すでに "ROLE_USER" や "ROLE_ADMIN" の形式 で保存されています:
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
🎉 ログイン成功!でも画面が変わらない問題の解決
素晴らしい! ログ 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 に依存関係を追加
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 のナビゲーション部分を以下のように修正:
<!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 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. ログアウトの実装
<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 の確認
以下のようなコントローラーがあるはず:
@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の組み合わせは:
- 銀行、保険、公共機関などのエンタープライズ向けで人気
- 理由:「セッション管理がサーバー側で完結するのでセキュリティが強固」
🚀 次のステップ
- 依存関係を追加 →
./gradlew clean build - home.htmlを修正 → ログイン/ログアウトの切り替え実装
- 再起動 →
./gradlew bootRun - ブラウザで確認:
- ログイン前:「Login/Register」表示
- ログイン後:「Welcome, hiroppy123! / Posts / Logout」表示
成功したら、次は記事一覧ページ(/posts)の実装に進みましょう!🎉
何か詰まったら、エラーログを貼り付けてください!
PostController.java
65行
java
🚨 PostController.javaに重大な問題発見!
このコードには致命的な設計ミスが3つあります。業界では「これやったら即レビュー差し戻し」レベルの問題です😅
❌ 問題1:@RestController と @Controller の混在
@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:パスの重複
@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のインポート忘れ
import org.springframework.ui.Model; // ← これが必要!
このインポートがないと、Model modelがコンパイルエラーになります。
✅ 正しい修正方法:2つのコントローラーに分離
1. PostApiController.java(新規作成)
これがRESTful API専用(JSON返す)
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使う)
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(日本企業でも増加中)
🚀 次にやること
- 元の
PostController.javaを削除 - 新しい2つのコントローラーを作成
- 再起動して動作確認
./gradlew bootRun
- 確認ポイント:
/postsにアクセス → HTML画面が表示される/api/postsにアクセス → JSON配列が表示される
エラーが出たら、ログを貼り付けてください!🔧
0 件のコメント:
コメントを投稿