SpringBoot Rest-Apiメール認証付きユーザー登録機能の実装

# 概要
SpringBoot　Rest-APIでメール認証付きのユーザー登録機能を実装します。

# 開発環境
OS:windows10
IDE:IntelliJ Community
spring-boot-starter-parent 2.75
java　：　11
postman :　E2Eテストツール

データベース
mysql:8.0.29
クライアントソフト: MySQL Workbench
# ユースケース
大まかな流れとしては以下の様になっています。
１．ユーザーがEmailとパスワードを入力する
２．システム側がDBにユーザー情報が仮登録し、入力されたEmailに認証メールを送る
３．ユーザーが認証メールのリンクを開く。
４．システム側が顧客の状態を更新する。
５．アカウント登録完了
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710623/aa266e8e-529f-3a4b-0eab-c25d8800ac0c.png)

# 実装

## 作成したファイル一覧
ファイル名 | 説明
----------|----
Customer.java|Entityクラス データベースの属性に該当します。
CustomerRepository.java|リポジトリレイヤー。データベースとの接続口　SpringJpaを利用
CustomerService.java| サービスクラス（インターフェイス）サービスの返り値と引数を定義
CustomerServiceImplement.java| サービスクラス（具象クラス）。ビジネスロジックの具体的な実装を定義
CustomerRestController.java|コントローラーレイヤー。URI　HttpMethod　返り値を定義するクラス
SignUpRequestBody.java|ユーザー登録する時のapiのRequestBodyを定義するクラス
VerifyRequestBody.java|ユーザーを認証する時のapiのRequestBodyを定義するクラス
application.property|送信元のEmailアドレス、アプリパスワードを設定するファイル

## 実装の方針
メール認証機能を実装するために、Customerテーブルにenabled(bool)とverification_code(６４桁の文字列)を追加します。
最初に登録されたときはenabled=true verification_codeは64桁の文字列です。
認証コードを照合するapiが実行され　成功した場合に enabled=true verification_code=nullになります。

送信元のメールはGmailを使います。

アプリパスワードはいつもログインの際に使うパスワードとは別物で、新規作成する必要があります。
アプリパスワードは16桁の文字列です。


## コード

### Customer.java 
```diff_java:Customer.java
package com.example.restapi.domain.customer;
@Entity
@Table(name = "customers")
@Data
public class Customer {

    // ID　データベースで使う
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "id", nullable = false)
    private Integer id;

    // Loginするときのパスワードして使う
    @Column(nullable = false, length = 64)
    private String password;

    // ログインするときのIDとして使用する
    @Column(nullable = false, unique = true, length = 45)
    private String email;

    // 認証コード
    @Column(name = "verification_code", length = 64)
    private String verificationCode;

    // 初期値はfalse 認証が成功すると trueになる。
    private boolean enabled;

    public Customer(Integer id, String password, String email, String verificationCode, boolean enabled) {
        this.id = id;
        this.password = password;
        this.email = email;
        this.verificationCode = verificationCode;
        this.enabled = enabled;
    }

    public Customer() {
    }

    public Customer( String email,String password) {
        this.password = password;
        this.email = email;
    }

    public Customer(String email,String password,String verificationCode, boolean enabled) {
        this.password = password;
        this.email = email;
        this.verificationCode = verificationCode;
        this.enabled = enabled;
    }
}


```

### CustomerRepository.java

```diff_java:CustomerRepository.java
package com.example.restapi.domain.customer;
@Repository
public interface CustomerRepository extends CrudRepository<Customer, Integer> {

    @Query("SELECT c FROM Customer c WHERE c.email = ?1")
    public Customer findByEmail(String email);
    boolean existsByEmail(String email);
    Customer save(Customer customer);
    @Query("SELECT c FROM Customer c WHERE c.verificationCode = ?1")
    Customer  findByVerificationCode(String verificationCode);

    @Query("UPDATE Customer c SET  c.verificationCode = null,c.enabled= true  WHERE c.id = ?1")
    @Modifying(clearAutomatically = true)
    public void enabled(Integer id);
}

```

### CustomerService.java

```diff_java:CustomerService.java
package com.example.restapi.domain.customer;

// todo extract register and login method from controller
@Service
public interface CustomerService {

        // ユーザー登録
        public Customer registerCustomer(String email,String password);

        // 認証コードを引数として、ユーザー情報を認証済みにする。
        public boolean verify(String verificationCode);
}


```

### CustomerServiceImplement.java

```diff_java:CustomerServiceImplement.java
package com.example.restapi.implement.customer;


@Service
@Transactional
public class CustomerServiceImplement implements CustomerService {

    @Autowired
    CustomerRepository customerRepository;

    @Autowired
    PasswordEncoder passwordEncoder;
    @Override
    public LoginDto findByEmail() {
        return null;
    }

    @Autowired
    private JavaMailSender javaMailSender;

    @Autowired
    ResourceLoader resourceLoader;

    @Value("${spring.mail.username}")
    private String fromEmail;

    @Override
    public Customer registerCustomer(String email, String password) {
        String randomCode = RandomString.make(64);
        Customer newCustomer = new Customer(email, passwordEncoder.encode(password), randomCode, false);
        customerRepository.save(newCustomer);
        sendVerifyMail(email,randomCode);

        return newCustomer;
    }
    //
    @Override
    public boolean verify(String verificationCode) {
        Customer customer = customerRepository.findByVerificationCode(verificationCode);
        if (customer == null || customer.isEnabled()) {
            return false;
        } else {
            customerRepository.enabled(customer.getId());
            return true;
        }

    }

    private boolean sendVerifyMail(String customerEmail,String verificationCode) {

        MimeMessage message = javaMailSender.createMimeMessage();
        MimeMessageHelper helper = null;


        // リンク先のページはフロントエンド(Next.js)側で実装する(dynamicRouting使う)
        // リンク先に飛んだときにverifyApiを実行する.
        // apiを実行する際にRequestBodyにverifyCodeを詰める

        try {
            helper = new MimeMessageHelper(message, true);
            helper.setFrom(fromEmail);
            helper.setTo(customerEmail);
            helper.setSubject("Ecshop Verification");
            String insertMessage = "<html>"
                    + "<head></head>"
                    + "<body>"
                    + "<h3>Hello " + customerEmail + "</h3>"
                    +"<div>ECショップのユーザー仮登録が完了しました。下リンクをクリックして有効化してください</div>"
                    +"<a href=http://localhost:3000/customer/verify/"+verificationCode+">ユーザの有効化</a>"
                    + "</body>"
                    + "</html>";

            helper.setText(insertMessage, true);
            javaMailSender.send(message);

        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }

        return true;
    }


```


### CustomerRestController.java

```diff_java:CustomerRestController.java
package com.example.restapi.implement.customer;

@RestController
public class CustomerRestController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private CustomerRepository customerRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private CustomerService customerService;

    @Autowired
    private MailSender mailSender;

    public static final String LINE_SEPARATOR = System.getProperty("line.separator");

    @PostMapping("/api/auth/signup")
    public ResponseEntity<?> registerUser(@RequestBody SignupRequestBody signupRequestBody){

        if(customerRepository.existsByEmail(signupRequestBody.getEmail())){
            return new ResponseEntity<>("すでに登録されているEmailです。",HttpStatus.BAD_REQUEST);
        }
        customerService.registerCustomer(signupRequestBody.getEmail(), signupRequestBody.getPassword());


        return new ResponseEntity<>("Customer registered successfully",HttpStatus.OK);
    }

    @PutMapping("/api/auth/verify")
    public ResponseEntity<?> verifyUser(  @RequestBody VerifyRequestBody verifyRequestBody ){
        if(!customerService.verify(verifyRequestBody.verifyCode)){
            return new ResponseEntity<>("認証コードが正しくありません",HttpStatus.BAD_REQUEST);
        }
        return new ResponseEntity<>("認証コードを承認しました。",HttpStatus.OK);
    }


```

### SignUpRequestBody.java

```diff_java:SignUpRequestBody.java
package com.example.restapi.implement.customer;

@Data
public class SignupRequestBody {
    private String email;
    private String password;
}


```

### VerifyRequestBody.java

```diff_java:VerifyRequestBody.java
package com.example.restapi.implement.customer;

import lombok.Data;

@Data
public class VerifyRequestBody {
    String verifyCode;
}


```

### application.property
ハイライト部は他の人に使われたら困るので適当な値を入れています。

```diff_properties:application.property

spring.mail.host=smtp.gmail.com
spring.mail.port=587
+spring.mail.username=xxxxxxx@gmail.com
+spring.mail.password=0123456789ABCDEF
spring.mail.properties.mail.smtp.auth = true
spring.mail.properties.mail.smtp.starttls.enable = true
spring.mail.defaultEncoding=UTF-8
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.socketFactory.port=465
spring.mail.properties.mail.smtp.socketFactory.class =javax.net.ssl.SSLSocketFactory
```




# 動かして検証
Postman、捨てメアドを使って期待する動きをするか確認します。

## Postmanでユーザー情報を入力してDBに追加する
email: ： m32d8ausub＠sute.jp(捨てメアド)
password:test
DBに追加されていることを確認できます。
パスワードは難読化されており、入力時とは一致していません。
以下gifアニメ
左側はPostman 右側はMySqlWorkbenchでDBの動きを確認しています。
![register-User1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710623/433c7b36-2f9c-e49f-4f3a-d23c22832cda.gif)



## メールを確認する
認証コードが入ったリンクを確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710623/b0e3f35a-c9bd-b9f1-9bc9-938c6be3c13d.png)



## Postmanを使ってユーザーを有効化する
メールのリンクに入っている認証コードをRequestBodyに詰めてverifyApiを実行します。
enabled=trueになったことを確認できます。

![register-verify.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710623/0771124f-40cc-0736-ae3f-b4f571164e85.gif)


# 課題
一定時間経過した未認証のユーザーを削除した方が良いと思った。
ただ実装方法がわからない。（SpringBatch使う？わからん）



# 参考資料

アプリパスワードの使い方

https://support.google.com/accounts/answer/185833?hl=ja


SpringBootでHTMLメールを送信をする方法

https://www.early2home.com/blog/programming/java/post-2102.html

application.propertyの設定を読み出す方法
@Valueを使う

https://qiita.com/kazuki43zoo/items/0ce92fce6d6f3b7bf8eb

検証に使った捨てメアドサイト

https://sute.jp/inbox


udemy
Section 15  Code Customer Registration

https://www.udemy.com/course/spring-boot-e-commerce-ultimate/