SpringBoot + Spring Securityで認証を行う


## はじめに

spring securityによるユーザ認証機能の投稿です。

※springboot, springsecurityのバージョンアップによって変更が必要になっていたので2019.10.14 に書き直しました。

## 1. 開発環境

| 項目名       |       値 |
|:-----------------|------------------:|
| OS             |              Windows 10 Home |
| jdk             |              AdoptOpenJDK |
| java             |              1.8 |
| gradle             |              5.5.1 |
| IDE             |              IntelliJ IDEA 2019.2.3(Community Edition)  |


## 2. プロジェクトテンプレートの作成

### (1) テンプレートの作成とダウンロード
[Spring Initializr](https://start.spring.io/)でテンプレートを作成します。リンクのページを、以下の表のように選択して、「Generate - Ctrl + ⏎」ボタンを押してダウンロードします。

| 項目名       |       値 |
|:-----------------|------------------:|
|Project | Maven Project
|Language | Java
|Spring Boot | (SNAPSHOT)2.1.9
|Developer Tools | Spring Boot DevTools, Lombok
|web | Spring Web
|Template Engines | Thymeleaf
|Security | Spring Security
|SQL | Spring Data JPA, PostgreSQL Driver


### (2) IDEにインポート

ダウンロードしたファイルを適当なフォルダに解凍します。その後、IntelliJ を開いて、「ファイル」→「開く」で、解凍したフォルダを指定するだけです。


## 3. プロジェクトフォルダ・ファイル構成
```
  spring-security
    │  build.gradle
    └─src
        ├─main
        │  ├─java
        │  │  └─com
        │  │      └─example
        │  │          └─security
        │  │              └─springsecurity
        │  │                  │  ServletInitializer.java
        │  │                  │  SpringsecurityApplication.java
        │  │                  │  WebSecurityConfig.java
        │  │                  │  
        │  │                  └─account
        │  │                         Account.java
        │  │                         AccountRepository.java
        │  │                         AccountService.java
        │  │                         AuthController.java
        │  │                         
        │  └─resources
        │      │  application.properties
        │      │  hibernate.properties
        │      │  
        │      ├─static
        │      └─templates
        │              login.html
        │              top.html
・・・（以下、省略）
```

## 4. build.gradle

dependenciesのみを掲載

```
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compile('org.springframework.security:spring-security-web')
	compile('org.springframework.security:spring-security-config')
	compile('org.thymeleaf.extras:thymeleaf-extras-springsecurity5')
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'org.postgresql:postgresql'
	annotationProcessor 'org.projectlombok:lombok'
	providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}
```

## 5. Java

前回から変更している箇所にコメントしています。

### (1) Entity

```java:Account.java

import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;

import javax.persistence.*;

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

@Entity
@Table(name="accounts")
public class Account implements UserDetails {

    private static final long serialVersionUID = 1L;

    //権限は一般ユーザ、マネージャ、システム管理者の３種類とする
    public enum Authority {ROLE_USER,ROLE_MANAGER, ROLE_ADMIN}

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

    @Column(nullable = false)
    private String password;

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

    @Column(nullable = false)
    private boolean mailAddressVerified;

    @Column(nullable = false)
    private boolean enabled;

    @Temporal(TemporalType.TIMESTAMP)
    private Date createdAt;

    // roleは複数管理できるように、Set<>で定義。
    @ElementCollection(fetch = FetchType.EAGER)
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Set<Authority> authorities;

    // JPA requirement
    protected Account() {}

    //コンストラクタ
    public Account(String username, String password, String mailAddress) {
        this.username = username;
        this.password = password;
        this.mailAddress = mailAddress;
        this.mailAddressVerified = false;
        this.enabled = true;
        this.authorities = EnumSet.of(Authority.ROLE_USER);
    }

    //登録時に、日時を自動セットする
    @PrePersist
    public void prePersist() {
        this.createdAt = new Date();
    }

    //admin権限チェック
    public boolean isAdmin() {
        return this.authorities.contains(Authority.ROLE_ADMIN);
    }

    //admin権限セット
    public void setAdmin(boolean isAdmin) {
        if (isAdmin) {
            this.authorities.add(Authority.ROLE_MANAGER);
            this.authorities.add(Authority.ROLE_ADMIN);
        } else {
            this.authorities.remove(Authority.ROLE_ADMIN);
        }
    }

    //管理者権限を保有しているか？
    public boolean isManager() {
        return this.authorities.contains(Authority.ROLE_MANAGER);
    }

    //管理者権限セット
    public void setManager(boolean isManager) {
        if (isManager) {
            this.authorities.add(Authority.ROLE_MANAGER);
        } else {
            this.authorities.remove(Authority.ROLE_MANAGER);
            this.authorities.remove(Authority.ROLE_ADMIN);
        }
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        for (Authority authority : this.authorities) {
            authorities.add(new SimpleGrantedAuthority(authority.toString()));
        }
        return authorities;
    }

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

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

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

    @Override
    public String getUsername() {
        return username;
    }

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

    @Override
    public String getPassword() {
        return password;
    }

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

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }

    public String getMailAddress() {
        return mailAddress;
    }

    public void setMailAddress(String mailAddress) {
        this.mailAddress = mailAddress;
    }
    public boolean isMailAddressVerified() {
        return mailAddressVerified;
    }
    public void setMailAddressVerified(boolean mailAddressVerified) {
        this.mailAddressVerified = mailAddressVerified;
    }
    public Date getCreatedAt() {
        return createdAt;
    }
}
```

### (2) Repository

```java:AccountRepository.java
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface AccountRepository extends CrudRepository<Account, Long> {
    public Account findByUsername(String username);
}
```

### (3) Service

```java:AccountService.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AccountService implements UserDetailsService {

    @Autowired
    private AccountRepository repository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public Account loadUserByUsername(String username) throws UsernameNotFoundException {
        if (username == null || "".equals(username)) {
            throw new UsernameNotFoundException("Username is empty");
        }

        Account user = repository.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("User not found: " + username);
        }

        return user;
    }

    //adminを登録するメソッド
    @Transactional
    public void registerAdmin(String username, String password, String mailAddress) {
        Account user = new Account(username, passwordEncoder.encode(password), mailAddress);
        user.setAdmin(true);
        repository.save(user);
    }

    //管理者を登録するメソッド
    @Transactional
    public void registerManager(String username, String password, String mailAddress) {
        Account user = new Account(username, passwordEncoder.encode(password), mailAddress);
        user.setManager(true);
        repository.save(user);
    }

    //一般ユーザを登録するメソッド
    @Transactional
    public void registerUser(String username, String password, String mailAddress) {
        Account user = new Account(username, passwordEncoder.encode(password), mailAddress);
        repository.save(user);
    }

}
```

### (4) Controller

```java:AuthController.java
import org.springframework.beans.factory.annotation.Autowired;
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.RequestMapping;

@Controller
public class AuthController {

    @RequestMapping("/")
    public String index() {
        return "redirect:/top";
    }

    @GetMapping("/login")
    public String login() {
        return "login";
    }

    @PostMapping("/login")
    public String loginPost() {
        return "redirect:/login-error";
    }

    @GetMapping("/login-error")
    public String loginError(Model model) {
        model.addAttribute("loginError", true);
        return "login";
    }

    @RequestMapping("/top")
    public String top() {
        return "/top";
    }

}
```

### (5) SecurityConfig

```java:WebSecurityConfig.java
import com.example.security.springsecurity.account.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
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;

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AccountService userService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //TODO: 最低限の実装。cssなどのstaticファイルなどの許可を追加する必要あります。
        http
                .authorizeRequests()
                .antMatchers("/login", "/login-error").permitAll()
                .antMatchers("/**").hasRole("USER")
                .and()
                .formLogin()
                .loginPage("/login").failureUrl("/login-error");
    }


    //変更点 ロード時に、「admin」ユーザを登録する。
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .userDetailsService(userService)
                .passwordEncoder(passwordEncoder());
        //TODO: propertyでadmin情報は管理しましょう。
        userService.registerAdmin("admin", "secret", "admin@localhost");
    }

    //変更点 PasswordEncoder(BCryptPasswordEncoder)メソッド
    @Bean
    public PasswordEncoder passwordEncoder() {
        //
        return new BCryptPasswordEncoder();
    }

}
```

## 6. Resources

### (1) login.html

```html:login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8" />
    <title>Login page</title>
    <style>
.alert-danger{color:red;}
</style>
</head>
<body>
<h2>ログイン画面</h2>
<form th:action="@{/login}" method="post">
    <div th:if="${session['SPRING_SECURITY_LAST_EXCEPTION']} != null"
         class="alert-danger">
        <span th:text="ユーザ名またはパスワードに誤りがあります"></span>
    </div>
    <div style="width:160px;"><label for="username">ユーザ名：</label></div>
    <input type="text" name="username" autofocus="autofocus" />
    <br/>
    <div style="width:160px;"><label for="password">パスワード：</label></div>
    <input type="password" name="password" />
    <br/>
    <p><input type="submit" value="ログイン" /></p>
</form>
</body>
</html>
```

### (2) top.html

```html:top.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
    <meta charset="UTF-8" />
    <title>メニュー画面</title>
</head>
<body>
<h2>こんにちは</h2>
<div th:fragment="logout" sec:authorize="isAuthenticated()">
    <p>こんにちは:
        <span sec:authentication="name"></span>さん</p>
    <p>mail:
        <span sec:authentication="principal.mailAddress"></span></p>
    <p>権限:
        <span sec:authentication="principal.authorities"></span></p>
    <form action="#" th:action="@{/logout}" method="post">
        <input type="submit" value="ログアウト" />
    </form>
</div>
</body>
</html>
```

### (3) application.properties

```properties:application.properties
spring.datasource.driverClassName=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/sampledb
spring.datasource.username=testuser
spring.datasource.password=secret
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
```

### (4) hibernate.properties

PostgreSQLに接続する場合、この設定ファイルを追記しましょう。

```properties:hibernate.properties
hibernate.jdbc.lob.non_contextual_creation = true
```

## 7. 動作確認

### (1) Spring bootの実行

IntelliJの「表示」→「ツールウィンドウ」→「Gradle」を選択して、「Gradle」ウィンドウを表示します。
「Gradle」ウィンドウの「Tasks」→「application」を選択していき、「bootRun」をダブルクリックします。

### (2) 動作確認

[http://localhost:8080](http://localhost:8080)にアクセスします。以下のようにログイン画面が表示され、認証後にtopページが表示されます。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246741/57dcd2f2-0c63-fbad-19e5-52dbe4c137d8.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/246741/fbc71b9e-e58f-12b5-e889-0670c9a6dac3.png)



参考サイト：
[ももいろテクノロジー Spring Securityでユーザ認証を実装してみる](http://inaz2.hatenablog.com/entry/2017/04/18/222721)