前回の章まででCRUD機能を実装することができました。
今回はログイン機能を実装していきたいと思います。
概要
ログイン機能を実装するのにSpringの「Spring Security」を利用していきます。
これは基本的に認証/認可の機能を提供しています。
今回はユーザIDとパスワードで認証する一般的なログイン画面を実装します。
※デフォルトの機能を使用して、ユーザ毎に権限を設定したり、パスワードの期限切れ等も設定することが可能です。
ログインせずに表示できるページ、リソースとログイン成功後に表示できるページというように制御していきます。
注意!
あくまでログイン機能を実装することが目的なのでセキュリティ的に十分というわけではないということをご理解ください。
実装
では実装していきます。
ここで使用するusersテーブルについては以下の章で既に作成済です。
また、あまり説明をしていないソースについてはアコーディオンで閉じた状態にしているので適宜プラスボタンを押し開いてご確認ください。
Spring Securityインストール
「Spring Security」を利用できるようにインストールします。
プロジェクト上で右クリック、「Spring」→「Add Starters」を選択します。
「Security」→「Spring Security」を追加します。
「build.gradle」を選択し、「Finish」を選択します。
これで利用可能となります。
WebSecurityConfig作成
ログイン機能の根本となるクラスです。
「@EnableWebSecurity」を付与して、「Spring Security」を有効化します。
configパッケージを作成し、「WebSecurityConfig.java」を作成します。
以下を記載します。
package diary.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import diary.service.MyUserService;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private MyUserService userService;
@Autowired
public WebSecurityConfig (MyUserService userService) {
this.userService = userService;
}
// URLパス毎に制御
@Override
public void configure(HttpSecurity http) throws Exception{
http
.authorizeRequests()
.antMatchers("/js/**", "/css/**", "/loginForm").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/loginForm")
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.defaultSuccessUrl("/diary", true)
.failureUrl("/loginForm?error=true");
}
// ユーザ情報の取得
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
}
// パスワードハッシュ化する
public BCryptPasswordEncoder passwordEncoder() {
BCryptPasswordEncoder bcpe = new BCryptPasswordEncoder();
return bcpe;
}
}
Entity作成
Entityパッケージ内に作成していきます。
DBで取得したユーザを格納する「User.java」を作成します。
package diary.entity;
public class User {
private int id;
private String userId;
private String password;
private String name;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
「MyUserDetails.java」を作成します。
このクラスはSpringが提供する「UserDetails」を継承しています。
上記で作成した「User.java」を内包して使用します。
package diary.entity;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class MyUserDetails implements UserDetails {
private static final long serialVersionUID = 1L;
private final User user;
public MyUserDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
メソッド | 内容 |
---|---|
getAuthorities() | 権限情報を返却 |
getPassword() | パスワードを返却 |
getUsername() | ユーザ名を返却 |
isAccountNonExpired() | アカウントが有効期限内かどうかを返却 |
isAccountNonLocked() | アカウントがロックかアンロックかを返却 |
isCredentialsNonExpired() | 資格情報の有効期限内かどうかを返却 |
isEnabled() | アカウントが有効なユーザかを返却 |
今回は使用しないのがほとんどです。
使用しないのメソッドには返却値を「true」にしています。
このメソッドが「false」を返却する場合はユーザ名、パスワードが正しくても認証エラーとなります。
Service作成
serviceパッケージ内に「MyUserService.java」を作成します。
package diary.service;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
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 diary.entity.MyUserDetails;
import diary.entity.User;
import diary.repository.IUserAccountDao;
@Service
public class MyUserService implements UserDetailsService {
private final IUserAccountDao dao;
@Autowired
public MyUserService(IUserAccountDao dao) {
this.dao = dao;
}
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
Optional<User> user = dao.findUser(userId);
if(!user.isPresent()) {
throw new UsernameNotFoundException(userId + "が存在しません");
}
return new MyUserDetails(user.get());
}
}
Repository作成
ユーザ情報を取得するためのDaoクラスを作成していきます。
まずはインターフェース「IUserAccountDao.java」をrepositoryパッケージ内に定義します。
package diary.repository;
import java.util.Optional;
import diary.entity.User;
public interface IUserAccountDao {
// Userを取得
Optional<User> findUser(String userId);
}
「UserAccountDao.java」を作成します。
package diary.repository;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import diary.entity.User;
@Repository
public class UserAccountDao implements IUserAccountDao {
private final NamedParameterJdbcTemplate jdbcTemplate;
@Autowired
public UserAccountDao(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public Optional<User> findUser(String userId) {
String sql = "SELECT id, user_id, password, username "
+ "FROM users "
+ "WHERE user_id = :user_id";
// パラメータ設定用Map
Map<String, Object> param = new HashMap<>();
param.put("user_id", userId);
User user = new User();
// 一件取得
try {
Map<String, Object> result = jdbcTemplate.queryForMap(sql, param);
user.setId((int) result.get("id"));
user.setUserId((String) result.get("user_id"));
user.setPassword((String)result.get("password"));
user.setName((String)result.get("username"));
}catch(EmptyResultDataAccessException e){
Optional <User> userOpl = Optional.ofNullable(user);
return userOpl;
}
// ラップする
Optional <User> userOpl = Optional.ofNullable(user);
return userOpl;
}
}
画面作成
ログインフォームを作成していきます。
「loginForm.html」を作成します。
※レイアウトは以下を参考にさせていただきました。
https://sounansa.net/archives/1522
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>日記アプリ</title>
<!-- bootstrapを読み込む -->
<link rel="stylesheet" type="text/css" th:href="@{/css/bootstrap.min.css}">
<script type="text/javascript" th:src="@{/js/bootstrap.min.js}"></script>
</head>
<body>
<div id="login">
<h3 class="text-center text-white pt-5">日記アプリ</h3>
<div class="container">
<div id="login-row" class="row justify-content-center align-items-center">
<div id="login-column" class="col-md-6">
<div id="login-box" class="col-md-12">
<form method="POST" id="login-form" class="form" th:action="@{/login}">
<h3 class="text-center text-info">Login</h3>
<div th:if="${param.error}">
<p class="text-danger">IDまたはパスワードが間違っています</p>
</div>
<div class="form-group">
<label for="username" class="text-info">Username:</label><br>
<input type="text" name="username" id="userId" class="form-control" required>
</div>
<div class="form-group">
<label for="password" class="text-info">Password:</label><br>
<input type="password" name="password" id="password" class="form-control" required>
</div>
<div class="form-group">
<input type="submit" name="submit" class="btn btn-info btn-md" value="ログイン">
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</body>
ログアウト実装
ログアウトもSpring Securityのデフォルト機能で簡単に実装することができます。
まずは各画面のヘッダーのログアウトボタン部分を修正していきます。
<form method="POST" th:action="@{/logout}">
<button type="submit" class="btn btn-link">ログアウト</button>
</form>
次に「WebSecurityConfig.java」の「configure(HttpSecurity http)」メソッドの末尾に3行追加します。
public void configure(HttpSecurity http) throws Exception{
http
.authorizeRequests()
.antMatchers("/js/**", "/css/**", "/loginForm").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/loginForm")
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.defaultSuccessUrl("/diary", true)
.failureUrl("/loginForm?error=true")
.and()
.logout()
.permitAll();
}
ログインユーザ表示
ログインユーザ名をヘッダーに表示したいと思います。
各画面のログインユーザと記載している部分を以下のように変更します。
<li class="nav-item ">
<span class="navbar-text" th:text="'ログインユーザ:' + ${#httpServletRequest.remoteUser}">></span>
</li>
動作確認
動作確認をしていきます。
実施する前にログインするためのユーザが必要なのでusesテーブルにデータを追加します。
パスワードはハッシュ化したものを登録したいので以下のようサイトを利用して作成してみてください。
https://toolbase.cc/text/bcrypt
①「http://localhost:8080/diary」にアクセスして、ログイン画面が表示されることを確認
②どちらか一方でも入力していない場合はエラーとなる
(HTMLの機能「required」を指定しています。)
③存在しないユーザを指定して、ログイン
エラーメッセージが表示され、画面遷移しないことを確認
④存在するユーザを入力して、ログイン
画面遷移し、ログインユーザ名が表示されていることを確認
パスワードが簡単すぎると、以下のようなメッセージが表示されます。
世の中で多く利用されているIDとパスワードの組み合わせの場合に表示されるようです。
(下の例は ブラウザ:Chrome パスワード:test(ハッシュ化前) で実施)
⑤ログアウトボタンを選択し、フォーム画面に戻ることを確認
最後に
ここまででログイン機能も実装することができました。
ですが、アプリ的には他にも色々実装しなければいけないことがあります。
気が向いたら調べてみてください。
例)
・セッションタイムアウト
・予期しないエラーが発生した際のエラー処理
・「一覧へ」ボタン選択時に前回入力の検索条件が保持されていない
などなど
次回はテストコードを書いていきたいと思います。