Webアプリ作成 Spring Boot
⑧ログイン機能

前回の章までで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;
	}
}
ポイント

@Configuration
 DIコンテナの定義されるよう付与しています。

@EnableWebSecurity
 前述でも記載しましたが、「Spring Security」を有効化しています。

extends WebSecurityConfigurerAdapter
 デフォルトで用意されているクラスを継承することで、簡単にカスタムすることが可能となります。

protected void configure(HttpSecurity http) throws Exception
 継承したクラスのメソッドをオーバーライドして使用しています。
 このクラスでは、URLによって認証が必要なのかどうかを定義しています。

.authorizeRequests()・・・①
.antMatchers(“/js/”, “/css/”, “/loginForm”).permitAll()
・・・②
.anyRequest().authenticated()
・・・③
 ①リクエスト時に認証が必要かを定義しています。
 ②静的ファイルであるjs、cssファイルとログイン画面は「permitAll()」として認証が必要ないで参照できるようにしています。
 ③でそれ以外の画面には認証が必要としています。
 ※上から順に適用されるということに注意してください。

.formLogin()・・・①
.loginPage(“/loginForm”)
・・・②
.loginProcessingUrl(“/login”)
・・・③
.usernameParameter(“username”)
・・・④
.passwordParameter(“password”)・・・⑤
.defaultSuccessUrl(“/diary”, true)・・・⑥
.failureUrl(“/loginForm?error=true”);・・・⑦
 ①でフォーム認証であることを定義しています。
 ②ログインページのURLです。
  (後に作成するControllerと紐づけます)
 ③指定したURLがリクエストされると認証処理を実施します。
 ④ユーザ名のパラメータ名を指定しています。
 ⑤パスワードのパラメータ名を指定しています。
 ⑥ログイン成功時の遷移先を記載しています。
 ⑦ログイン失敗時の遷移先を記載しています。
  「error=true」というパラメータを付加してエラーメッセージを表示するのに使用します。

public void configure(AuthenticationManagerBuilder auth)
 認証処理を行うのに必要なメソッドです。
 後ほど作成する「userService」を実行しています。
 またパスワードはBCryptアルゴリズムを利用してパスワードをハッシュ化して認証しています。

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;
	}
	
}
ポイント

★ポイント
MyUserDetails.java
 ユーザとパスワードを入力しユーザが存在した場合にその他のチェックをすることができます。

メソッド内容
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());
	}
}
ポイント

implements UserDetailsService
 ユーザ情報を取得するためのインターフェースがSpringには用意されています。
 こちらを利用して、ユーザを取得します

loadUserByUsername
 ユーザIDをキーにユーザ情報を取得します。
 パスワードをキーにしないのと思うかもしれませんが、その辺の認証処理はConfig内に記載しました「configure(AuthenticationManagerBuilder auth)」メソッドを実行することで認証しています。
 ※このメソッド内でServiceクラスを呼んでいます。

★ポイント
implements UserDetailsService
 ユーザ情報を取得するためのインターフェースがSpringには用意されています。
 こちらを利用して、ユーザを取得します

loadUserByUsername
 ユーザIDをキーにユーザ情報を取得します。
 パスワードをキーにしないのと思うかもしれませんが、その辺の認証処理はConfig内に記載しました「configure(AuthenticationManagerBuilder auth)」メソッドを実行することで認証しています。
 ※このメソッド内でServiceクラスを呼んでいます。

Optional user = dao.findOne(userId);
 ユーザIDでユーザ情報が取得できない可能性があるのでOptionalクラスで受け取っています。
 daoクラスはこの後実装していきます。
 ユーザが取得できた場合は「MyUserDetail」クラスとして返却しています。

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>
ポイント

form method=”POST” id=”login-form” class=”form” th:action=”@{/login}”
 actionにて「/login」を指定しています。
 「.loginProcessingUrl(“/login”)」と合わせています。

<div th:if=”${param.error}”>
 認証できなかった際はエラーメッセージを表示するようにしています。

ログアウト実装

ログアウトも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();
	}
ポイント

th:action=”@{/logout}”
 logoutというパスでsubmitするとデフォルトでログアウトしてくれます。

.logout().permitAll();
 ログアウト成功時に遷移するパスへのアクセス件を付与しています。
 ※ログアウト成功時はデフォルトでログインフォームを表示するためのパスとなります。
  「.logoutSuccessUrl」で変更することも可能です。

ログインユーザ表示


ログインユーザ名をヘッダーに表示したいと思います。
各画面のログインユーザと記載している部分を以下のように変更します。

          <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(ハッシュ化前) で実施)

⑤ログアウトボタンを選択し、フォーム画面に戻ることを確認

最後に

ここまででログイン機能も実装することができました。
ですが、アプリ的には他にも色々実装しなければいけないことがあります。
気が向いたら調べてみてください。
例)
・セッションタイムアウト
・予期しないエラーが発生した際のエラー処理
・「一覧へ」ボタン選択時に前回入力の検索条件が保持されていない
などなど

次回はテストコードを書いていきたいと思います。

参考文献

Spring Boot ログイン画面 - 公式サンプルコード
Spring Boot の概要から各機能の詳細までが網羅された公式リファレンスドキュメントです。開発者が最初に読むべきドキュメントです。
タイトルとURLをコピーしました