Spring BootでTodoアプリ1 -ログイン機能作成-

前置き

以前Spring徹底入門を読んでから、業務で触れることなかったのでいったんSpring勉強してなかった。 週末にまとまった勉強時間が取れるようになって、やりたい勉強できるようになったのでSpring Bootでアプリ作ってみた。 相も変わらず作成したアプリはHerokuにアップ。
Spring徹底入門はリファレンスとしてアプリ作りながら読むとすごく勉強になる。

アプリの内容

Todoアプリ。ログイン機能とTodoを作る。
DBはHerokuで使用できるPostgreSQL
本記事ではログイン機能を作成。
f:id:n-yata:20210320061108p:plain

テーブル作成

Todoアプリ用DBを作って、ユーザー管理テーブルを作成。

CREATE DATABASE tododb;

DROP TABLE IF EXISTS usr CASCADE;
CREATE TABLE IF NOT EXISTS usr(
  user_id VARCHAR(255) NOT NULL,
  first_name VARCHAR(255) NOT NULL,
  last_name VARCHAR(255) NOT NULL,
  password VARCHAR(255) NOT NULL,
  role_name VARCHAR(255) NOT NULL,
  PRIMARY KEY (user_id)
);

インサートするデータはこんな感じ。

INSERT INTO usr (user_id, first_name, last_name, role_name, password)
VALUES ('user', '太郎', '山田', 'USER', '$2a$10$ZqU0cizLk7c6YEydWPfpr.3Gmomn5JtYOfBqEExy3muSNKm3sZY7a');

passwordはBCryptでハッシュ値に変換したものを入れとく。
上記のパスワードは「password」をハッシュ化したもの。
ハッシュ化は下記のサイトで計算できる。(ありがたい)
BCrypt ハッシュ値 計算 | tekboy

ストレッチング回数は10回に指定してハッシュ化。 f:id:n-yata:20210320063125p:plain

プロジェクト作成

eclipseで開発。最近のpleiadesならSTS標準で入ってるはず。
Spring Boot 開発環境構築 Eclipse STS インストール - 公式ガイド

プロジェクトを作成。
f:id:n-yata:20210320063714p:plainf:id:n-yata:20210320063725p:plain

pom.xmlのdependenciesタグ内にSpring Securityを追加。

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>

application.propertiesにPostgreSQLの接続情報を追加。
ついでにログ出力も設定。

# DB接続設定
spring.datasource.url=jdbc:postgresql://localhost:5432/tododb
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true

# ログ出力の設定
logging.file.name=C:/logs/todoSpring.log
#logging.level.org.springframework.web=DEBUG
logging.level.org.springframework.web=INFO

HTMLの作成

ログイン画面のHTMLを作成。
「/SpringTodo/src/main/resources/templates」内にlogin.htmlを作成。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<meta charset="UTF-8">
<title>Login Form</title>
</head>
<body>
    <div th:if="${param.error}">
        Invalid username and password.
     </div>
     <div th:if="${param.logout}">
        You have been logged out.
     </div>
     <form th:action="@{/login}" method="post">
        <div><label> User Name : <input type="text" name="username" /></label></div>
        <div><label> Password : <input type="password" name="password" /></label></div>
        <div><input type="submit" value="Sign In"></div>
     </form>
</body>
</html>

ログインが成功したときの遷移先を作成。
todo.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org"
      xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
    <head>
        <title>Hello World!</title>
    </head>
    <body>
        <h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
        <form th:action="@{/logout}" method="post">
            <input type="submit" value="Sign Out"/>
        </form>
    </body>
</html>

Javaを書く

作成するソースは下記。
/SpringTodo/src/main/java/nyata/
- MvcConfig.java
- SpringTodoApplication.java(今回は編集しない)
- WebSecurityConfig.java

/SpringTodo/src/main/java/nyata/domain/model
- User.java
- RoleName.java

/SpringTodo/src/main/java/nyata/domain/repositoriy
- UserRepository.java

/SpringTodo/src/main/java/nyata/domain/service
- TodoUserDetails.java
- TodoUserDetailsService.java

MvcConfig.java

URLの遷移先を管理

package nyata;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * View Controller
 * @author nyata
 */
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/login").setViewName("login");
        registry.addViewController("/").setViewName("login");
        registry.addViewController("/todo").setViewName("todo");
    }
}
WebSecurityConfig.java

Spring Securityの設定情報。
パスワードのハッシュ化としてBCryptを使用。

package nyata;

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.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import nyata.domain.service.TodoUserDetailsService;

/**
 * Security Config
 * @author nyata
 */
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    TodoUserDetailsService userDetailsService;

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/js/**", "/css/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .defaultSuccessUrl("/todo", true)
                .permitAll()
                .and()
                .logout()
                .permitAll();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
}
RoleName.java
package nyata.domain.model;

/**
 * ユーザー権限一覧
 * @author nyata
 */
public enum RoleName {
    ADMIN, USER
}
User.java
package nyata.domain.model;

import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.Id;
import javax.persistence.Table;

import org.hibernate.annotations.Proxy;

/**
 * ユーザー情報のエンティティ
 * @author nyata
 */
@Entity
@Proxy(lazy = false)
@Table(name = "usr")
public class User {
    /* ユーザーID */
    @Id
    private String userId;
    /* パスワード */
    private String password;
    /* ファーストネーム */
    private String firstName;
    /* ラストネーム */
    private String lastName;
    /* ユーザー権限 */
    @Enumerated(EnumType.STRING)
    private RoleName roleName;

    // setter, getter
    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 getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public RoleName getRoleName() {
        return roleName;
    }

    public void setRoleName(RoleName roleName) {
        this.roleName = roleName;
    }
}
UserRepository.java

JPAを使用するためのリポジトリ

package nyata.domain.repositoriy;

import org.springframework.data.jpa.repository.JpaRepository;

import nyata.domain.model.User;

/**
 * ユーザー情報のリポジトリ
 * @author nyata
 */
public interface UserRepository extends JpaRepository<User, String> {
}
TodoUserDetails.java

ユーザー認証の設定

package nyata.domain.service;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;

import nyata.domain.model.User;

/**
 * ユーザー認証情報
 * @author nyata
 */
public class TodoUserDetails implements UserDetails {
    private final User user;

    public TodoUserDetails(User user) {
        this.user = user;
    }

    public User getUser() {
        return user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return AuthorityUtils.createAuthorityList("ROLE_" + this.user.getRoleName().name());
    }

    @Override
    public String getPassword() {
        return this.user.getPassword();
    }

    @Override
    public String getUsername() {
        return this.user.getUserId();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}
TodoUserDetailsService.java

ユーザー認証処理を受け持つ

package nyata.domain.service;

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 nyata.domain.model.User;
import nyata.domain.repositoriy.UserRepository;

/**
 * ユーザー認証のサービス
 * @author nyata
 */
@Service
public class TodoUserDetailsService implements UserDetailsService {
    @Autowired
    UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.getOne(username);
        if (user == null) {
            throw new UsernameNotFoundException(username + " is not found.");
        }
        return new TodoUserDetails(user);
    }
}

ひとまずこれでログイン機能完成。次はTodo機能を作っていく。

参考文献

Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発(株式会社NTTデータ)|翔泳社の本
Spring Boot ログイン画面 - 公式サンプルコード
BCrypt ハッシュ値 計算 | tekboy
Spring Bootでログを出力する | DevelopersIO
SpringSecurity-AuthenticatedPricipalは非推奨 - Javaer101