【Java】Spring Boot 3によるWebアプリ開発入門 ~Step4:Spring Securityによるログイン機能~

1. はじめに

本シリーズではSpring Boot3を用いてクイズアプリの開発する方法をご紹介しています。ひととおり実践して基礎を理解してもらえれば、自分が作りたいWebアプリを開発することができると思いますので、ぜひ挑戦してみてください。下記のStep1の環境構築、Step2のPostgresデータベースの構築、Step3でクイズアプリ実装に続いて、Step4ではSpring Securityによるログイン機能を実装していきます。
【Java】Spring Boot 3によるWebアプリ開発入門 ~Step1:環境構築~
【Java】Spring Boot 3によるWebアプリ開発入門~Step2:データベース構築~
【Java】Spring Boot 3によるWebアプリ開発入門 ~Step3:クイズアプリ実装~

2. Spring Securityを適用したアーキテクチャ概要

本アプリではSpring Securityというフレームワークを適用しています。Spring Securityを適用することで、包括的なセキュリティ機能を実装することができるため、意図せずにセキュリティホールを作ってしまうことを未然に防ぐことができます。下記にSpring Securityを用いた認証のフローを記載します。

処理の大まかな流れをご説明します。

  1. 認証リクエストを受け取ると、Authentication Filterが起動します。
  2. Authentication Filterがリクエストからユーザ、パスワードを抽出して認証情報を生成し、Authentication Managerの認証処理を実行します。
  3. Authenticaion ManagerはDAO Authentication Providerを呼び出して認証処理を実行します。
  4. DAO Authentication ProviderからUserDetailsServiceを呼び出し、該当ユーザのユーザ名およびパスワードを取得処理が実行されます。
  5. データベースに対するCRUD処理を行うために、UserDetailsServiceからRepositoryが呼び出されます。
  6. Repositoryがデータベースに対して必要なCRUD処理を行い、該当ユーザのユーザ名およびパスワードを取得してRepositoryに返却します。
  7. UserDetailsServiceがDAO Authentication Providerに取得した情報を返却します。
  8. DAO Authentication ProviderからPassword Encoderを呼び出し、入力されたパスワードをハッシュ化します。
  9. DAO Authentication Providerにてパスワード照合によるユーザ認証を行い、認証結果をAuthentication Managerに返却します。
  10. 認証に成功した場合、AuthenticatinSuccessHandlerを呼び出します。
  11. AuthenticatinSuccessHandlerに定義されている処理を実行し、Spring MVCのControllerに処理を受け渡します。

3. pom.xmlの設定

Spring Securityを導入するためにpom.xmlに定義を追加していきます。pom.xmlファイル全体のサンプルを記載します。

Spring Securityのライブラリを使うために①spring-boot-starter-security、thymeleaf連携させるために②thymeleaf-extras-springsecurity6を設定しています。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
		 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.3.6</version>
		<relativePath/>
	</parent>
	<groupId>com.luv2code.springboot</groupId>
	<artifactId>cruddemo</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>cruddemo</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>17</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
        //① Thymelearを使うためのdependencyを追加
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
        //② Spring Securityを使うためのdependencyを追加
		<dependency>
			<groupId>org.thymeleaf.extras</groupId>
			<artifactId>thymeleaf-extras-springsecurity6</artifactId>
		</dependency>
		<dependency>
			<groupId>org.postgresql</groupId>
			<artifactId>postgresql</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-validation</artifactId>
		</dependency>
	</dependencies>
</project>

4. SecuriyConfig.javaの実装

Spring Securityの設定をSecurityConfig.javaに記載していきます。アーキテクチャ概要図におけるAuthentication Filter、Authentication Manager、DAOAuthenticationProvider、PasswordEncoderを含んでいます。

まず、SecurityFilterChainを実装していきましょう。
①authorize.requestMachers以降にリクエストごとに必要なセキュリティ対策を記載しています。ユーザ登録(/register/**)および全ユーザ公開用のコンテンツ(/quiz/public/**)についてはpermitAll()としており、ログインしなくてもアクセスできるようにしています。一方でユーザごとのコンテンツ(/quiz/list/user/**)についてはADMINロールをもつユーザでログインした場合しかアクセスできないようにしています。
②formLoginにてログインページ(/login)を設定しています。ログインに失敗した場合はfailureUrlでerrorパラメタを引き渡すことで、ログインエラーメッセージを出力させてます。さらにsuccessHandlerメソッドでログイン成功した後の挙動を詳細に定義しています。
③logoutRequestMatcherでログアウトの挙動を定義しています。
④AuthenticationManagerのBean定義をするためのメソッドを作成します。AuthenticationManagerBuilderにuserDetailsServiceを設定することで、DAOAuthenticationProviderを有効化します。さらにDAOAuthenticationProvider
⑤PasswordEncoderを設定することで、パスワードをハッシュ化します。

package eeengineer.quizapp.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
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.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import eeengineer.quizapp.security.CustomAuthenticationSuccessHandler;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    //① requestMachersの定義
        http.authorizeHttpRequests((authorize) ->
                        authorize.requestMatchers("/register/**").permitAll()
                                .requestMatchers("/quiz/public/**").permitAll()
                                .requestMatchers("/quiz/list/user/**").hasRole("ADMIN")
        //② ログインページの設定
                ).formLogin(
                        form -> form
                        	.loginPage("/quiz/public")
                                .loginProcessingUrl("/login")
                                .failureUrl("/login?error")
                                .successHandler(new CustomAuthenticationSuccessHandler())
                                .permitAll()
        //③ ログアウト設定
                ).logout(
                        logout -> logout
                                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                                .permitAll()
                );
        return http.build();
    }

    @Autowired
    private UserDetailsService userDetailsService;
    
  //④ AuthenticationManagerの設定
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder());
    }
    
  //⑤Encoderの設定
    @Bean
    static PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

5. UserDetailsServiceの実装

インターフェースであるUserDetailsServiceに対して、実装クラスであるCustomUserDetailsService を作成します
①usernameを引数にしてloadUserByUsernameメソッドを実行することで、UserDetailsオブジェクトを取得します。
②ユーザ名、パスワードおよびRoleオブジェクトのリストを取得します。
③mapRolesToAuthoritiesメソッドを定義することで、Roleオブジェクトのリストを取得しています。

package eeengineer.quizapp.security;

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 eeengineer.quizapp.entity.Role;
import eeengineer.quizapp.entity.User;
import eeengineer.quizapp.repository.UserRepository;

import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

  //① UserDetailsオブジェクトを取得
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username);

        if (user != null) {
            return new org.springframework.security.core.userdetails.User(
                    //②ユーザ情報の取得
            	    user.getUsername(),
                    user.getPassword(),
                    mapRolesToAuthorities(user.getRoles()));
        }else{
            throw new UsernameNotFoundException("Invalid username or password.");
        }
    }

  //③Roleオブジェクトの取得
    private Collection < ? extends GrantedAuthority> mapRolesToAuthorities(List<Role> list) {
        Collection < ? extends GrantedAuthority> mapRoles = list.stream()
                .map(role -> new SimpleGrantedAuthority(role.getRolename()))
                .collect(Collectors.toList());
        return mapRoles;
    }
}

UserDetailsServiceにてimportしているEntityとしてUser、Role、RepositoryとしてUserRepository、について記載します。
まずUserクラスです。userid、username、password、roleを定義しており、それぞれgetterメソッド、setterメソッドを定義しています。

package eeengineer.quizapp.entity;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name="users")
public class User
{
    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer userid;

    @Column(nullable=false)
    private String username;

    @Column(nullable=false)
    private String password;

  //①多対多の多重度を持つUserテーブルとRoleテーブルを関連付け
    @ManyToMany(fetch = FetchType.EAGER, cascade=CascadeType.ALL)
    @JoinTable(
            name="users_roles",
            joinColumns={@JoinColumn(name="USER_ID", referencedColumnName="USERID")},
            inverseJoinColumns={@JoinColumn(name="ROLE_ID", referencedColumnName="ROLEID")})
    private List<Role> roles = new ArrayList<>();

	public Integer getId() {
		return userid;
	}

	public void setId(Integer userid) {
		this.userid = userid;
	}

	public String getUsername() {
		return username;
	}

	public void setUsername(String username) {
		this.username = username;
	}

	public String getPassword() {
		return password;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	public List<Role> getRoles() {
		return roles;
	}

	public void setRoles(List<Role> roles) {
		this.roles = roles;
	}

	public static long getSerialversionuid() {
		return serialVersionUID;
	}
}

続いてRoleクラスです。ロールID、ロール名を定義しています。①さらにUserテーブルとの多対多の関係性を定義しています。

package eeengineer.quizapp.entity;

import jakarta.persistence.*;
import java.util.List;

@Entity
@Table(name="roles")
public class Role
{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long roleid;

    @Column(nullable=false, unique=true)
    private String rolename;

  //①多対多の多重度を持つUserテーブルとRoleテーブルを関連付け
    @ManyToMany(mappedBy="roles")
    private List<User> users;

	public Long getId() {
		return roleid;
	}

	public void setId(Long roleid) {
		this.roleid = roleid;
	}

	public String getRolename() {
		return rolename;
	}

	public void setRolename(String rolename) {
		this.rolename =rolename;
	}

	public List<User> getUsers() {
		return users;
	}

	public void setUsers(List<User> users) {
		this.users = users;
	}
}

続いてUserRepositoryです。①Spring Data JPAにて提供されているfindByUsernameメソッドを定義しており、CustomUserDetailsServiceでこのメソッドを呼び出すことでデータベースからユーザ名を取得しています。

package eeengineer.quizapp.repository;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import eeengineer.quizapp.entity.User;

public interface UserRepository extends JpaRepository<User, Long> {
  //①ユーザ名をデータベースから取得
    User findByUsername(String username);
    List<User> getUseridByUsername(String username);
    User getUsernameByUserid(Integer userid);
}

6. AuthenticatinSuccessHandlerの実装

Spring Securityによる認証に成功した場合の処理をCustomAuthenticationSuccessHandlerにて実装しています。①リダイレクトしてユーザごとのコンテンツを表示する処理を記載しています。

package eeengineer.quizapp.security;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
     //①ユーザ名を取得してリダイレクト先のURLを指定しています。
   	String username = authentication.getName();
        String redirectUrl = "/quiz/list/user/"+username;
        response.sendRedirect(redirectUrl);
    }   
}

7. Spring MVCの実装

以上がSpringSecurityの認証/認可の実装になります。続いてSpring MVCモデルのサンプルコードを共有していきます。
まずはControllerとなります。
①ユーザ登録に関する処理を記載しています。UserDtoというDTO(Data Transfer Object)を作成し、register画面で登録したユーザ情報を一時的に保存します。
②DTOに保存したユーザ情報をuserserviceのsaveUserメソッドでデータベースに保存します。
③ログイン画面を表示させます。ユーザ名とパスワードを入力する画面となります。
④ログインしていないユーザ向けのクイズリストを表示させています。SecuriyConfig.javaにて「requestMatchers(“/quiz/public/**”).permitAll()」と指定しているため、誰でもアクセス可能なページとしています。
⑤ログインしていないユーザ向けのクイズを表示させています。
⑥ログイン後のユーザごとのクイズリストを表示させています。ログイン成功後にAuthenticatinSuccessHandlerで呼び出しているURLとなります。
⑦ユーザごとのクイズを表示させています。
⑧ユーザごとのクイズのステータス(完了 or 未完了)を変更する処理を記載しています。

package eeengineer.quizapp.controller;

import eeengineer.quizapp.dto.UserDto;
import eeengineer.quizapp.entity.Quiz;
import eeengineer.quizapp.entity.User;
import eeengineer.quizapp.service.QuizService;
import eeengineer.quizapp.service.UserService;
import jakarta.validation.Valid;

import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@Controller
public class QuizController {

    private QuizService quizService;
    private UserService userService;

    public QuizController(QuizService theQuizService, UserService theUserService) {
        quizService = theQuizService;
        userService = theUserService;
    }
    
  //①ユーザ情報の一次登録
    @GetMapping("register")
    public String showRegistrationForm(Model model){
        UserDto user = new UserDto();
        model.addAttribute("user", user);
        return "register";
    }
    
  //②ユーザ登録
    @PostMapping("/register/save")
    public String registration(@Valid @ModelAttribute UserDto user,
                               BindingResult result,
                               Model model){
        User existing = userService.findByUsername(user.getUsername());
        if (existing != null) {
            result.rejectValue("username", null);
        }
        if (result.hasErrors()) {
            model.addAttribute("user", user);
            return "redirect:/register?failure";
        }
        userService.saveUser(user);
        return "redirect:/register?success";
    }

  //③ログイン画面表示
    @GetMapping("/login")
    public String loginForm() {
        return "login";
    }
    
  //④ログインしていないユーザ向けのクイズ表示
    @GetMapping("/quiz/public")
    public String QuizByUser(Model theModel) {
    	List<User> userid = userService.getUseridByUsername("public");
    	Integer userid01 = userid.get(0).getId();    	
    	List<Quiz> theQuiz = quizService.findQuizByUserid(userid01);

        theModel.addAttribute("quiz", theQuiz);

        return "publicQuizList";
    }
    
  //⑤ログインしていないユーザ向けのクイズリスト表示
    @GetMapping("/quiz/public/{quizid}")
    public String QuizByIDForPublic(@PathVariable Integer quizid, Model theModel) {

        Quiz theQuiz = quizService.findById(quizid);
        Integer userid = theQuiz.getUserid();
        User theUser = userService.getUsernameByUserid(userid);

        theModel.addAttribute("quiz", theQuiz);
        theModel.addAttribute("user", theUser);
        
        return "publicQuiz";
    }
    
  //⑥ユーザごとのクイズリスト表示
    @GetMapping("/quiz/list/user/{username}")
    public String QuizByUser(@PathVariable String username, Model theModel) {
    	List<User> userid = userService.getUseridByUsername(username);
    	Integer userid01 = userid.get(0).getId();    	
    	List<Quiz> theQuiz = quizService.findQuizByUserid(userid01);

        theModel.addAttribute("quiz", theQuiz);
        
        String loginUsername = SecurityContextHolder.getContext().getAuthentication().getName();

        if(loginUsername.equals(username)) {
            return "userQuizList";
        }else {
        	return "login";
        }
    }

    //⑦ユーザごとのクイズ表示
    @GetMapping("/quiz/list/user/{username}/{quizid}")
    public String QuizByID(@PathVariable String username, @PathVariable Integer quizid, Model theModel) {
    	
        Quiz theQuiz = quizService.findById(quizid);
        Integer userid = theQuiz.getUserid();
        User theUser = userService.getUsernameByUserid(userid);

        // add to the spring model
        theModel.addAttribute("quiz", theQuiz);
        theModel.addAttribute("user", theUser);
        
        String loginUsername = SecurityContextHolder.getContext().getAuthentication().getName();

        if(loginUsername.equals(username)) {
            return "userQuiz";
        }else {
        	return "login";
        }
    }

    //⑧ユーザごとのクイズステータス変更
    @GetMapping("/quiz/list/user/{username}/toggleStatus")
    public String changeStatus(Integer quizid) {
        System.out.println(quizid);
        Quiz theQuiz = quizService.findById(quizid);
        quizService.changeStatus(quizid);
        
        Integer userid = theQuiz.getUserid();
        User theUser = userService.getUsernameByUserid(userid);
        String username = theUser.getUsername();
        return "redirect:/quiz/list/user/"+username;
    }
}

続いてControllerから呼び出しているUserDtoについてコードを共有します。Userクラスとほとんど同じですが、DTOを用いることでUserテーブルを不必要に参照/更新することなくユーザ登録を実現することができます。

package eeengineer.quizapp.dto;

import jakarta.validation.constraints.NotEmpty;

public class UserDto
{
    private Long userid;
    @NotEmpty
    private String username;
    @NotEmpty(message = "パスワードの入力は必須です。")
    private String password;

    public Long getId() {
		return userid;
	}
	public void setId(Long userid) {
		this.userid = userid;
	}
	public String getUsername() {
		return username;
	}
	public void setUsername(String username) {
		this.username = username;
	}
	public String getPassword() {
		return password;
	}
	public void setPassword(String password) {
		this.password = password;
	}
}

続いてControllerから呼び出しているuserserviceについてコードを共有します。ユーザ登録やユーザ検索などのメソッドを定義しています。実際の動作についてはUserServiceImplクラスで定義しています。

package eeengineer.quizapp.service;

import java.util.List;

import eeengineer.quizapp.dto.UserDto;
import eeengineer.quizapp.entity.User;

public interface UserService {
    void saveUser(UserDto userDto);

    User findByUsername(String username);

    List<UserDto> findAllUsers();

    List<User> getUseridByUsername(String username);

  User getUsernameByUserid(Integer userid);
}

UserServiceImplのサンプルコードを連携します。
①DTOに一時保存していた情報を用いて、ユーザ名、パスワード、ロール名を登録します。
②既存ロールにROLE_ADMINが存在しない場合はcheckRoleExist()メソッドを呼び出します。
③checkRoleExistメソッドにてROLE_ADMINを登録しています。
④ユーザにロール(ROLE_ADMIN)を付与します。
⑤ユーザにを登録します。

package eeengineer.quizapp.service;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import eeengineer.quizapp.dto.UserDto;
import eeengineer.quizapp.entity.User;
import eeengineer.quizapp.entity.Role;
import eeengineer.quizapp.repository.RoleRepository;
import eeengineer.quizapp.repository.UserRepository;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class UserServiceImpl implements UserService {

    private UserRepository userRepository;
    private RoleRepository roleRepository;
    private PasswordEncoder passwordEncoder;

    public UserServiceImpl(UserRepository userRepository,
                           RoleRepository roleRepository,
                           PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.roleRepository = roleRepository;
        this.passwordEncoder = passwordEncoder;
    }

  //①ユーザ名、パスワード、ロール登録
    @Override
    public void saveUser(UserDto userDto) {
        User user = new User();
        user.setUsername(userDto.getUsername());
        user.setPassword(passwordEncoder.encode(userDto.getPassword()));

    //②ロールにROLE_ADMINが存在しない場合、checkRoleExist()メソッドを呼び出す。
        Role role = roleRepository.findByRolename("ROLE_ADMIN");
        if(role == null){
            role = checkRoleExist();
        }

    //④ユーザにロール(ROLE_ADMIN)を付与する。
        user.setRoles(Arrays.asList(role));
        //⑤ユーザにを登録する。
        userRepository.save(user);
    }

  //③ROLE_ADMINを登録する。
    private Role checkRoleExist() {
        Role role = new Role();
        role.setRolename("ROLE_ADMIN");
        return roleRepository.save(role);
    }

    @Override
    public User findByUsername(String username) {
        return userRepository.findByUsername(username);
    }

    @Override
    public List<User> getUseridByUsername(String username) {
        return userRepository.getUseridByUsername(username);
    }
    
    @Override
    public User getUsernameByUserid(Integer userid) {
        return userRepository.getUsernameByUserid(userid);
    }
    
    @Override
    public List<UserDto> findAllUsers() {
        List<User> users = userRepository.findAll();
        return users.stream().map((user) -> convertEntityToDto(user))
                .collect(Collectors.toList());
    }

    private UserDto convertEntityToDto(User user){
        UserDto userDto = new UserDto();
        String name = user.getUsername();
        userDto.setUsername(name);
        return userDto;
    }
}

8. Thyeleafによる画面実装

続いてThyeleafによるユーザ登録、ユーザ認証画面を実装していきます。
まずはログイン画面(login.html)です。①にユーザ登録画面へのリンクを記載しています。

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org"
>
<head>
    <meta charset="UTF-8">
    <title>ログイン/ユーザ登録画面</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top" th:fragment="header">
    <div class="container-fluid">
        <a class="navbar-brand" th:href="@{/}">クイズリストへ</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
    </div>
</nav>
<br />
<br />
<br />
<div class="container">
    <div class="row">
        <div class="col-md-6 offset-md-3">
            <div th:if="${param.error}">
                <div class="alert alert-danger">ユーザ名またはパスワードが誤っています。</div>
            </div>
            <div th:if="${param.logout}">
                <div class="alert alert-success">ログアウトしました。</div>
            </div>
            <div class="card">
                <div class="card-header">
                    <h3 class="text-center">ログイン画面</h3>
                </div>
                <div class="card-body">
                    <form
                            method="post"
                            th:action="@{/login}"
                            class="form-horizontal"
                            role="form"
                    >
                        <div class="mb-3">
                            <label for="username" class="control-label">ユーザ名 *</label>
                            <input type="text"
                                   id="username"
                                   name="username"
                                   class="form-control"
                                   placeholder="ユーザ名"
                            />
                        </div>

                        <div class="mb-3">
                            <label for="password" class="control-label">パスワード *</label>
                            <input type="password"
                                   id="password"
                                   name="password"
                                   class="form-control"
                                   placeholder="パスワード"
                            />
                        </div>

            <!--①ユーザ登録画面へのリンク-->
                        <div class="mb-3">
                            <button type="submit" class="btn btn-primary">ログイン</button>
                            <span> ユーザ登録は右のリンクから
                            <a th:href="@{/register}"> ユーザ登録</a>
                        </span>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
</body>
</html>

続いてユーザ登録画面(register.html)を下記の通り作成します。

<!DOCTYPE html>
<html lang="en"
  xmlns:th="http://www.thymeleaf.org"
>
<head>
    <meta charset="UTF-8">
    <title>Registration and Login System</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
        rel="stylesheet"
        integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
        crossorigin="anonymous">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
  <div class="container-fluid">
    <a class="navbar-brand" th:href="@{/index}">クイズリスト</a>
    <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
      <span class="navbar-toggler-icon"></span>
    </button>
  </div>
</nav>
<br /><br /><br />
<div class="container">
  <div class="row col-md-8 offset-md-2">
    <div class="card">
      <div class="card-header">
          <h2 class="text-center">ユーザ登録</h2>
      </div>
      <div th:if="${param.success}">
          <div class="alert alert-info">
            ユーザ登録が完了しました。
          </div>
      </div>
      </div th:if="${param.failure}">
	  <div class="alert alert-danger">
	    すでに登録済みのユーザ名です。別のユーザ名を指定してください。
	  </div>
      </div>
      <div class="card-body">
          <form
            method="post"
            role="form"
            th:action="@{/register/save}"
            th:object="${user}"
          >
            <div class="form-group mb-3">
              <label class="form-label">ユーザ名</label>
              <input
                      class="form-control"
                      id="username"
                      name="username"
                      placeholder="ユーザ名"
                      th:field="*{username}"
                      type="text"
              />
              <p th:errors = "*{username}" class="text-danger"
              th:if="${#fields.hasErrors('username')}"></p>
            </div>

            <div class="form-group mb-3">
              <label class="form-label">パスワード</label>
              <input
                      class="form-control"
                      id="password"
                      name="password"
                      placeholder="パスワード"
                      th:field="*{password}"
                      type="password"
              />
              <p th:errors = "*{password}" class="text-danger"
                 th:if="${#fields.hasErrors('password')}"></p>
            </div>
            <div class="form-group">
              <button class="btn btn-primary" type="submit">登録</button>
              <span>ユーザ登録済み? <a th:href="@{/login}">こちらからログイン</a></span>
            </div>
          </form>
      </div>
    </div>
  </div>
</div>
</body>
</html>

9. Spring Securityによるユーザ認証の動作確認

Spring Securityによる認証に必要なソースコードは以上です。それでは最後に動作確認をしてみましょう。
動作確認するにあたりサンプルでデータを登録していきます。

まず、publicユーザをUserテーブルに登録します。本アプリにおいてはpublicユーザ向けのコンテンツはユーザ認証なしで参照できるようにしています。

quiz=# INSERT INTO USERS VALUES(1,'public','password');
INSERT 0 1
quiz=# select * from users;
 userid | username | password
--------+----------+----------
      1 | public   | password
(1 行)

続いてサンプルでクイズを登録していきます。ユーザのステータスを管理するためにユーザごとにクイズを登録する必要があります。For文で1000ユーザ分のクイズを登録しています。

delete from quiz;

select setval('quiz_quizid_seq', 1, false);

DO $$
BEGIN
    FOR i IN 1..1000 LOOP
        INSERT INTO quiz VALUES(default,i,'Linuxのシステムに関する一般的なログファイル名は?','Linux','/var/log/logs','/var/log/messages','/var/log/maillog','/var/log/spooler','1','/var/log/messagesは、UnixおよびUnix系オペレーティングシステム(例えばLinux)で使用される標準的なログファイルの一つです。このファイルはシステムやアプリケーションからの重要なメッセージやイベントを記録するために使用されます。','未完了');
        INSERT INTO quiz VALUES(default,i,'ファイルの末尾から100行を表示させるコマンドは?','Linux','head -n 100','head -l 100','tail -n 100','tail -l 100','3','tailコマンドは指定したファイルの末尾の内容を表示するための Linux コマンドです。オプション-nで表示する行数を指定します。デフォルトでは最後の 10 行を表示します。','未完了');
        INSERT INTO quiz VALUES(default,i,'新規作成するファイルのパーミッションが全て644になるようにする方法は?','Linux','umask 644','umask 022','chmod 644','chmod 022','2','umaskに022を指定するとデフォルトの権限は644となります。一方でディレクトリは755になりますので注意してください。','未完了');        
    END LOOP;
END;
$$;

いよいよクイズアプリの動作確認をしていきます。ブラウザを起動してhttp://localhost:8080にアクセスすると、http://localhost:8080/quiz/publicにリダイレクトされてpublicユーザ向けのクイズリストが表示されます。下記画面が表示されたら、「ユーザ登録/ログイン」へのリンクを押下します。

下記の通りログイン画面が出てきます。まずはユーザ登録が必要なので、「ユーザ登録」のリンクをクリックします。

下記の通りユーザ登録画面が出てきますので、一意のユーザ名と任意のパスワードを入力して「登録」ボタンを押下します。

下記の通り「ユーザ登録が完了しました。」というメッセージが出ていれば成功です。

ユーザ名がほかのユーザと重複していた場合は、下記のようなエラー画面が出力されますので別のユーザ名で再登録します。

続いて登録したユーザでログインしてみましょう。「こちらからログイン」のリンクをクリックして下さい。

先ほど登録したユーザの情報を入力して、「ログイン」ボタンを押下します。

下記の通り、testユーザ用のクイズリストが表示されたらログイン成功です。

ユーザ名あるいはパスワードが誤っていたら、下記のようなエラーメッセージが出力されます。

10. まとめ

Spring BootおよびSpring Securityを適用したクイズアプリ作成の説明は以上となります。フレームワークを用いることで最低限のセキュリティを担保したアプリケーション作成がスムーズにできることが分かったかと思います。ソースコードを下記のGithubにアップロードしていますので、参考にして開発してみてください。
eeengineering/quizapp-step4

11. 参考文献

コメント