
今回は、Spring Bootでユーザー登録時のパスワードを安全にハッシュ化する方法を解説します。
Controllerで@RequestParamを使って、画面から送られてきた値を1つずつ受け取ります。
たとえば、名前、メールアドレス、パスワードを次のように受け取ります。
@RequestParam String name
@RequestParam String email
@RequestParam String password
ただし、パスワードだけは絶対にそのままDBへ保存してはいけません。
Spring Bootでは、Spring SecurityのPasswordEncoderを使って、パスワードをハッシュ化してから保存します。
OWASPのPassword Storage Cheat Sheetでは、パスワードを平文で保存してはならず、Argon2id、bcrypt、PBKDF2のような強くて遅いハッシュアルゴリズムで保護すべきだと説明されています。また、SHA-256のような高速なハッシュ関数は、攻撃者が大量の推測を高速に試せるため、パスワード保存には適していないとされています。
今回作る構成
今回は、次の構成で作ります。
| 部品 | 役割 |
|---|---|
| Thymeleaf | 登録画面とログイン画面を表示する |
| Controller | @RequestParamで入力値を受け取る |
| Service | パスワードのハッシュ化とログイン判定を行う |
| DAO | DBへの登録と検索を行う |
| PasswordEncoder | パスワードをハッシュ化し、照合する |
流れは次のようになります。
ユーザー登録画面
↓
name、email、passwordをPOST送信
↓
Controllerが@RequestParamで受け取る
↓
Serviceがpasswordをハッシュ化する
↓
DAOがDBに保存する
ログイン時は次の流れです。
ログイン画面
↓
email、passwordをPOST送信
↓
Controllerが@RequestParamで受け取る
↓
DAOがemailでユーザーを検索する
↓
PasswordEncoder.matchesで照合する
↓
一致すればログイン成功pom.xmlに依存関係を追加する
PasswordEncoderを使いたい場合は、spring-security-cryptoを追加します。
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>PasswordEncoderをBeanとして登録する
まず、PasswordEncoderをSpring管理の部品として登録します。
Spring管理の部品をBeanと呼びます。
Beanにしておくと、Serviceなどのクラスで簡単に使えるようになります。
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}PasswordEncoderFactories.createDelegatingPasswordEncoder()を使うと、DelegatingPasswordEncoderが作られます。
DelegatingPasswordEncoderは、保存されたパスワードの先頭に付いた{id}を見て、どのPasswordEncoderで照合するかを判断します。Spring Securityのドキュメントでも、保存形式は{id}encodedPasswordという形で、idが利用するPasswordEncoderを探すための識別子になると説明されています。
たとえば、保存されるハッシュ値は次のような形になります。
{bcrypt}$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
{bcrypt}は、「このパスワードはbcrypt方式でハッシュ化されています」という目印です。
たとえるなら、荷物に「冷蔵」「常温」「割れ物注意」とラベルを貼るようなものです。
あとで照合するときに、Spring Securityがラベルを見て正しい方式を選んでくれます。
usersテーブルを作る
次に、ユーザー情報を保存するテーブルを作ります。
大事なのは、passwordではなくpassword_hashというカラム名にしている点です。
ここには、生のパスワードではなく、ハッシュ化済みのパスワードを保存します。
CREATE TABLE users (
user_id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);| カラム | 意味 |
|---|---|
| user_id | ユーザーを識別するID |
| name | ユーザー名 |
| ログインに使うメールアドレス | |
| password_hash | ハッシュ化済みパスワード |
| created_at | 登録日時 |
password_hashという名前にしておくと、「ここに平文パスワードを入れてはいけない」と見ただけでわかります。
UserDtoを作る
DBから取得したユーザー情報を入れるDTOを作ります。
DTOとは、Data Transfer Objectの略です。
データを運ぶための入れ物だと考えてください。
package com.example.demo.model.dto;
public class UserDto {
private long userId;
private String name;
private String email;
private String passwordHash;
public long getUserId() {
return userId;
}
public void setUserId(long userId) {
this.userId = userId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
}passwordHashは、ログイン時の照合に使います。
ただし、画面に表示してはいけません。
ハッシュ化済みとはいえ、パスワードに関係する情報を画面やログに出すのは避けましょう。
SuperDaoを作る
DB接続を共通化するため、SuperDaoを作ります。
package com.example.demo.dao;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
/**
* SuperDao - データベース接続の取得を担当する基底クラス
*/
public class SuperDao {
// 接続情報は本来外部ファイル化すべきですが、教育上の簡略化のため直接記述します
private static final String DB_URI =
"jdbc:mysql://localhost:3306/sip_a?characterEncoding=utf8&"
+ "useSSL=false&serverTimezone=GMT%2B9&"
+ "rewriteBatchedStatements=true";
private static final String DB_USER = "newuser";
private static final String DB_PASS = "0";
/**
* データベース接続を取得する
* 呼び出し元で try-with-resources を使用して確実に close することを想定
* @return データベース接続オブジェクト
* @throws SQLException 接続失敗時にスロー
*/
protected Connection getConnection() throws SQLException {
return DriverManager.getConnection(DB_URI, DB_USER, DB_PASS);
}
}DB名、ユーザー名、パスワードは自分の環境に合わせて変更してください。
実務では、DB接続情報をコードに直接書くのではなく、application.propertiesや環境変数で管理することが多いです。
UserDaoを作る
次に、ユーザー登録とメールアドレス検索を行うDAOを作ります。
DAOとは、Data Access Objectの略です。
DBアクセス専用のクラスです。
package com.example.demo.model.dao;
import com.example.demo.model.dto.UserDto;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import org.springframework.stereotype.Repository;
@Repository
public class UserDao extends SuperDao {
public void insertUser(String name, String email, String passwordHash) {
String sql =
"INSERT INTO users (name, email, password_hash) "
+ "VALUES (?, ?, ?)";
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(sql)) {
ps.setString(1, name);
ps.setString(2, email);
ps.setString(3, passwordHash);
ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("ユーザー登録に失敗しました。", e);
}
}
public UserDto findByEmail(String email) {
String sql =
"SELECT user_id, name, email, password_hash "
+ "FROM users "
+ "WHERE email = ?";
try (Connection con = getConnection();
PreparedStatement ps = con.prepareStatement(sql)) {
ps.setString(1, email);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
UserDto user = new UserDto();
user.setUserId(rs.getLong("user_id"));
user.setName(rs.getString("name"));
user.setEmail(rs.getString("email"));
user.setPasswordHash(rs.getString("password_hash"));
return user;
}
return null;
}
} catch (SQLException e) {
throw new RuntimeException("ユーザー検索に失敗しました。", e);
}
}
}insertUserでは、passwordHashをDBに保存しています。
passwordという生のパスワードを保存していない点に注目してください。
findByEmailでは、ログイン時にメールアドレスでユーザーを探します。
該当ユーザーがいない場合はnullを返します。
UserServiceを作る
次に、パスワードのハッシュ化とログイン判定を行うServiceを作ります。
Serviceのメソッドは、name、email、passwordを文字列として受け取ります。
package com.example.demo.service;
import com.example.demo.model.dao.UserDao;
import com.example.demo.model.dto.UserDto;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserDao userDao;
private final PasswordEncoder passwordEncoder;
public UserService(UserDao userDao, PasswordEncoder passwordEncoder) {
this.userDao = userDao;
this.passwordEncoder = passwordEncoder;
}
public void register(String name, String email, String rawPassword) {
String passwordHash = passwordEncoder.encode(rawPassword);
userDao.insertUser(name, email, passwordHash);
}
public UserDto login(String email, String rawPassword) {
UserDto user = userDao.findByEmail(email);
if (user == null) {
return null;
}
boolean matched =
passwordEncoder.matches(rawPassword, user.getPasswordHash());
if (!matched) {
return null;
}
return user;
}
}ここが一番重要です。
| 場面 | 使うメソッド | 意味 |
|---|---|---|
| 登録時 | passwordEncoder.encode(rawPassword) | 入力されたパスワードをハッシュ化する |
| ログイン時 | passwordEncoder.matches(rawPassword, user.getPasswordHash()) | 入力パスワードとDBのハッシュ値を照合する |
Spring SecurityのPasswordEncoder APIでも、encodeは生パスワードをエンコードし、matchesは入力された生パスワードと保存済みのエンコード済みパスワードが一致するか確認すると説明されています。
ログイン時に、次のようにencodeしてequalsで比較してはいけません。
String inputHash = passwordEncoder.encode(rawPassword);
if (inputHash.equals(user.getPasswordHash())) {
// ログイン成功
}bcryptでは、同じパスワードでも毎回違うハッシュ値になることがあります。
内部でランダムなソルトが使われるためです。
そのため、文字列比較ではなくmatchesを使います。
boolean matched =
passwordEncoder.matches(rawPassword, user.getPasswordHash());UserControllerを作る
次に、Controllerを作ります。
package com.example.demo.controller;
import com.example.demo.model.dto.UserDto;
import com.example.demo.service.UserService;
import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/register")
public String showRegisterForm() {
return "user/register";
}
@PostMapping("/register")
public String register(
@RequestParam String name,
@RequestParam String email,
@RequestParam String password,
Model model) {
if (name.isBlank() || email.isBlank() || password.isBlank()) {
model.addAttribute("errorMessage", "すべての項目を入力してください。");
return "user/register";
}
userService.register(name, email, password);
return "redirect:/login";
}
@GetMapping("/login")
public String showLoginForm() {
return "user/login";
}
@PostMapping("/login")
public String login(
@RequestParam String email,
@RequestParam String password,
HttpSession session,
Model model) {
UserDto user = userService.login(email, password);
if (user == null) {
model.addAttribute("errorMessage",
"メールアドレスまたはパスワードが正しくありません。");
return "user/login";
}
session.setAttribute("loginUser", user);
return "redirect:/mypage";
}
@GetMapping("/mypage")
public String mypage(HttpSession session, Model model) {
UserDto loginUser =
(UserDto) session.getAttribute("loginUser");
if (loginUser == null) {
return "redirect:/login";
}
model.addAttribute("loginUser", loginUser);
return "user/mypage";
}
}登録時には、name、email、passwordを@RequestParamで直接受け取っています。
@RequestParam String name @RequestParam String email @RequestParam String password
ログイン時にも、emailとpasswordを@RequestParamで直接受け取っています。
@RequestParam String email @RequestParam String password
ただし、入力項目が増えるとControllerの引数が長くなります。
その場合は、あとでDTOを検討するとよいです。
登録画面を作る
次に、Thymeleafの登録画面を作ります。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ユーザー登録</title>
</head>
<body>
<h1>ユーザー登録</h1>
<p th:if="${errorMessage}" th:text="${errorMessage}"></p>
<form th:action="@{/register}" method="post">
<div>
<label for="name">名前</label>
<input type="text" id="name" name="name">
</div>
<div>
<label for="email">メールアドレス</label>
<input type="email" id="email" name="email">
</div>
<div>
<label for="password">パスワード</label>
<input type="password" id="password" name="password">
</div>
<button type="submit">登録</button>
</form>
</body>
</html>ログイン画面を作る
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ログイン</title>
</head>
<body>
<h1>ログイン</h1>
<p th:if="${errorMessage}" th:text="${errorMessage}"></p>
<form th:action="@{/login}" method="post">
<div>
<label for="email">メールアドレス</label>
<input type="email" id="email" name="email">
</div>
<div>
<label for="password">パスワード</label>
<input type="password" id="password" name="password">
</div>
<button type="submit">ログイン</button>
</form>
</body>
</html>マイページを作る
ログイン成功後の確認用に、簡単なマイページを作ります。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>マイページ</title>
</head>
<body>
<h1>マイページ</h1>
<p>ログインに成功しました。</p>
<p>
名前:
<span th:text="${loginUser.name}"></span>
</p>
<p>
メールアドレス:
<span th:text="${loginUser.email}"></span>
</p>
</body>
</html>passwordHashは表示していません。
パスワードに関係する情報は、画面に出さないのが基本です。
登録時にDBへ保存される値のイメージ
たとえば、登録画面で次のように入力したとします。
| 項目 | 入力値 |
|---|---|
| 名前 | 山崎太郎 |
| メールアドレス | yamazaki@example.com |
| パスワード | 123456789 |
DBには、次のように保存されるイメージです。
| name | password_hash | |
|---|---|---|
| 山崎太郎 | yamazaki@example.com | {bcrypt}$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
password_hashには、123456789そのものは保存されません。
ハッシュ化された長い文字列が保存されます。
この状態なら、DBを見ても元のパスワードはわかりません。
ログイン時にやってはいけない比較
ログイン時に、入力パスワードとDBのハッシュ値をequalsで比較してはいけません。
悪い例です。
if (password.equals(user.getPasswordHash())) {
return user;
}これは、生パスワードとハッシュ値を比較しています。
一致するはずがありません。
次の書き方も避けます。
String inputHash = passwordEncoder.encode(password);
if (inputHash.equals(user.getPasswordHash())) {
return user;
}bcryptでは、同じパスワードをencodeしても、毎回違う文字列になることがあります。
正しい書き方です。
boolean matched =
passwordEncoder.matches(password, user.getPasswordHash());
if (matched) {
return user;
}ログイン時は、必ずmatchesを使いましょう。
セキュリティ上の注意点
パスワードを扱うときは、次の点に注意してください。
| 注意点 | 理由 |
|---|---|
| パスワードを平文で保存しない | DB流出時に全ユーザーのパスワードが漏れるため |
| パスワードをログに出さない | ログファイルから漏れる可能性があるため |
| SHA-256単体で保存しない | 高速すぎて総当たり攻撃に弱いため |
| ログイン時はmatchesを使う | bcryptではequals比較できないため |
| password_hashを画面に表示しない | 攻撃者に不要な情報を与えるため |
Spring Securityのパスワード保存ドキュメントでも、SHA-256のような暗号学的ハッシュは現在ではパスワード保存に安全ではなく、bcrypt、PBKDF2、scrypt、argon2のような適応型一方向関数を使うべきだと説明されています。
セキュリティは「動けばOK」ではありません。
攻撃されたときにも守れる作りにする必要があります。
まとめ
ServiceでPasswordEncoderを使ってパスワードをハッシュ化し、DAOでDBに保存します。
| 処理 | 使うもの | 内容 |
|---|---|---|
| 登録画面 | name属性 | name、email、passwordを送信する |
| Controller | @RequestParam | 送信値を直接受け取る |
| Service | passwordEncoder.encode | パスワードをハッシュ化する |
| DAO | PreparedStatement | ハッシュ化済みパスワードを保存する |
| ログイン | passwordEncoder.matches | 入力パスワードと保存済みハッシュを照合する |
新人エンジニアは、まず次の対応関係を覚えてください。
<input name="password">
↓
@RequestParam String password
↓
passwordEncoder.encode(password)
↓
password_hashへ保存
ログイン時は、次の流れです。
<input name="password">
↓
@RequestParam String password
↓
DBからpassword_hashを取得
↓
passwordEncoder.matches(password, passwordHash)
↓
trueならログイン成功今後の学習では、@RequestParam、PasswordEncoder、BCrypt、DAO、セッション、ログインチェック、バリデーション、Spring Securityの認証機能を順番に学ぶとよいです。まずは、パスワードを平文保存しないこと、ログイン時はmatchesで照合することを徹底してください!