SpringBootでログイン機能作ってみた

Java

今回もセキュリティ関連です!

前回は「spring-boot-starter-security」が提供してくれるBASIC認証をやってみたので、今回は、自分でログイン機能を作ってデータベース認証をやってみたいと思います!

何が違うかというと、ログインを許可するユーザとパスワードを設定ファイルではなく、DBに持たせることができるので、汎用性が高くなります。

BASIC認証だと、みんな同じユーザIDとパスワードを使うことになりますし、セキュリティはガバガバですね

プロトタイプなんかで、かつIPアドレスが絞れないような環境でクライアント認証させたいようなシステムだと結構このBASIC認証は簡単に実装できるので威力を発揮するんじゃないかなーと思ってます。

では!早速作っちゃいましょう!

手順は以下の通りです。

1:dependency の追加

2:JavaConfig関連の作成

3:認証処理クラスを作成

4:DBアクセス関連処理の作成

5:リクエストハンドラーの作成

6:テンプレート側の作成

ちなみに、今回はDBにH2を使用してデータベースアクセスにはMyBatisを使います

1:dependency の追加

早速、pom.xmlを修正します!

<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>2.1.0</version>
		</dependency>

		<dependency>
			<groupId>com.h2database</groupId>
			<artifactId>h2</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

特段、取り上げるものは何もないですね。

2:JavaConfig関連の作成

こやつが新参者で、何をしているかというとSpringフレームワークがもつ機能の設定をいじってるって感じです。

ソースはこんな感じ

package com.soloware.taskapp.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.builders.WebSecurity;
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 com.soloware.taskapp.domain.service.UserService;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

  @Autowired
  private UserService userService;

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
	BCryptPasswordEncoder bcpe = new BCryptPasswordEncoder();
        return bcpe;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login") // ログインのビュー
                .loginProcessingUrl("/sign_in") //認証処理が実行される
                .usernameParameter("username") 
                .passwordParameter("password")
                .successForwardUrl("/hello")
                .failureUrl("/login?error")
                .permitAll()
                .and()
            .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login?logout")
                .permitAll();
    }

    @Autowired
    public void configure(AuthenticationManagerBuilder auth) throws Exception{
    	auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
    }
}

@Configurationアノテーションでこのクラスは設定情報を記述しているんですよ!とSpringに教えています。

続く@EnableWebSecurityってのは、SpringSecurityが提供しているConfigurationクラスをインポートし、SpringSecurityを利用するために必要となるコンポーネントのBean定義を自動で行われるようにするものです

で、UserServiceっていうサービスクラスをDIしている。このクラスについては後述する

そのあとのこいつ

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
	BCryptPasswordEncoder bcpe = new BCryptPasswordEncoder();
        return bcpe;
    }

こいつは、入力されたパスワードをエンコードするクラスでSpring側のライブラリ。

@Beanアノテーションをつけて、DIコンテナにこのエンコードクラスを登録してます。

続くこいつ。

   @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login") 
                .loginProcessingUrl("/sign_in") 
                .usernameParameter("username") 
                .passwordParameter("password")
                .successForwardUrl("/hello")
                .failureUrl("/login?error")
                .permitAll()
                .and()
            .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login?logout")
                .permitAll();
    }

こいつは、スーパークラスの「WebSecurityConfigurerAdapter」が持ってるメソッドで、最初のauthorizeRequests().anyRequest().authenticated()ってとこはよーわからん!笑

おろらく、すべてのリクエストに対して認証されているかチェックしまっせって感じ!

かな!わからん!また調べときいます。

で、次のformLogin().loginPage(“/login”) ってとこは、formLoginメソッドを呼び出すことで、フォーミ認証を有効にしており、FormLoginConfigurerのインスタンスが返ってきます。このインスタンスには、フォーム認証で使用するコンポーネントの動作をカスタマイズするためのメソッドが定義されており、その1つがloginPageメソッドです。このメソッドでフォーム認証に使用するフォームが存在する認証画面は「/login」ってパスやでってことを指定しています。

で、次のloginProcessingUrlメソッドが引数に指定した「/sign_in」ってパスにリクエストがあったら、「username」と「password」のパラメータを使って認証をかけます!成功したらsuccessForwardUrlメソッドの引数に指定している「/hello」ってパスにフォワードして、

失敗したらfailureUrlで指定している「/login」ってパスにerrorっていうパラメータつけて返します。

続く、permitAllメソッドは、すべてのユーザに対してログインフォームへのアクセス権を付与するためのメソッドです。

で、次のlogout().logoutUrl(“/logout”).logoutSuccessUrl(“/login?logout”)てのが

ログアウト用のパスは「/logout」でここにリクエストが来たらセッション破棄など認証情報を初期化して「/login」ってパスにlogoutっていうパラメータをつけて返します。

ちなみに、SpringSecurityでは、以下のような流れでログアウト処理を行う

・クライアントは、ログアウト処理を行うためのパスにリクエストを送信する。ここでいう「/logout」のこと

・LogoutFilterは、LogoutHandlerのメソッドを呼び出してログアウト処理を行う

・LogoutFilterは、LogoutSuccessHandlerのメソッドを呼び出して画面遷移を行う。

.logoutメソッドを呼び出すことで、ログアウト機能が有効になり、LogoutConfigurerのインスタンスが返されます。

そして。。。

このクラスの最後の部分

    @Autowired
    public void configure(AuthenticationManagerBuilder auth) throws Exception{
    	auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
    }

こいつは、configureってメソッドをDIしてて認証処理をごにょごにょしてます。笑

このuserDetailsServiceってのもSpring側のライブラリで、このクラスを継承した独自クラスを作成する必要があります。後述!

次は、本サイトにアクセスがあった場合にlogin画面を返す必要があるのでその設定です

package com.soloware.taskapp.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
@Configuration
public class LoginConfig implements WebMvcConfigurer {
 
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/login").setViewName("login");
    }
 
}

例に倣って、@Configurationアノテーションです。

addViewControllersの引数で取得したクラスのaddViewControllerメソッドを呼びます。この引数にはパスを指定し、続くsetViewNameにhtmlファイル名を書きます。

以上!

3:認証処理クラスを作成

次は先ほどから後述すると先延ばしにしてきたUserServiceクラスです

package com.soloware.taskapp.domain.service;


import java.util.ArrayList;
import java.util.List;

import com.soloware.taskapp.domain.dao.LoginUserDao;
import com.soloware.taskapp.domain.data.LoginUser;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
 
@Service
@Transactional
public class UserService implements UserDetailsService {
	@Autowired
	LoginUserDao loginUserDao;
 
	@Override
	public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
		LoginUser user = loginUserDao.findUser(userName);
		System.out.println("LOGINUSER INSTANCE");

		if (user == null) {
			throw new UsernameNotFoundException("userName" + userName + "was not found in the database");
		}
 
		List<GrantedAuthority> grantList = new ArrayList<GrantedAuthority>();
		GrantedAuthority authority = new SimpleGrantedAuthority("USER");
		grantList.add(authority);
 
		BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
 
		UserDetails userDetails = (UserDetails) new User(user.getUser_name(), encoder.encode(user.getPassword()),
				grantList);
		return userDetails;
	}
}

はい、こいつのメソッドはDBへデータを更新するため@Transactionalアノテーションをクラス全体につけます

で、実際にMyBatisを使ってデータアクセスをするDAOクラスをDIしています。こいつは後述

注目すべきは、「UserDetailsService」っていうSpring側のライブラリクラスを実装していて、loadUserByUsernameメソッドをオーバーライドしています。

UserDetailsServiceは資格情報とユーザの状態をデータストアから取得するためのインターフェースで、以下のメソッドが定義されている。

public interface UserDetailsService {
  UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

オーバーライドしたメソッドですね!

このメソッドの最初でMyBatisを使ってユーザ名と一致するデータを取得しエンティティクラスであるLoginUserクラスに情報を格納して返します。

データベースに情報が見つからなかったら例外を吐きます。この結果がWebConfigSecurityクラスの

            .formLogin()
                .loginPage("/login") 
                .loginProcessingUrl("/sign_in") 
                .usernameParameter("username") 
                .passwordParameter("password")
                .successForwardUrl("/hello")
                .failureUrl("/login?error")
                .permitAll()
                .and()

このfailureUrlに流れていくイメージ!

続くこいつが、DBに作成する権限テーブル関連の処理になっている

		List<GrantedAuthority> grantList = new ArrayList<GrantedAuthority>();
		GrantedAuthority authority = new SimpleGrantedAuthority("USER");
		grantList.add(authority);

今回は権限テーブルとしてUSERテーブルを作ります!このテーブルにユーザ名とパスワードを登録しておくことで、認証が成功するという仕組みです!

最後の部分はエンコードクラスをインスタンス化してDBから取得したパスワードをエンコーディングさせ、ユーザ名、パスワード、権限リストをそれぞれフィールドに持ったUserクラスをUserDetailsインターフェースに格納して返します。

		BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
 
		UserDetails userDetails = (UserDetails) new User(user.getUser_name(), encoder.encode(user.getPassword()),
				grantList);
		return userDetails;
	}

BCryptPasswordEncoderは、BCryptアルゴリズムを使用してパスワードのハッシュ化およびパスワードの照合を行う実装クラスで、ソルトには16バイトの乱数が使用され、デフォルトでは、1024回ストレッチングを行っているみたい。

※ソルト:パスワードに追加する文字列のこと。パスワードにソルトを追加して実際のパスワードより桁数を長くすると、レインボークラックなどのパスワード解析を困難にすることができる。

※ストレッチング:ハッシュ値の計算を繰り返し行うこと。ストレッチングを多く行いパスワード解析に必要になる時間を増やすと、パスワードの総当たり攻撃などによるパスワード解析を困難にすることができる。この回数は多いほど、強度はますがサーバへの負荷は高くなる。

ちなみに、SpringSecurityには、BCrypt以外にStandardPasswordEncoderとNoOpPasswordEncoderがあり、どれもPasswordEncoderの実装クラスです。

StandardPasswordEncoderは、SHA-256アルゴリズムを使用してパスワードのハッシュ化および照合を行う。

NoOpPasswordEncoderは、ハッシュ化をしません。テスト用のクラスとして用意されているので、実際のアプリケーションで使用することはないですね!

ちなみに、このUserクラスとUserDetailsインターフェース両方ともSpring側のライブラリです。

UseDetailsは、認証処理で必要となる資格情報(ユーザIDとパスワード)とユーザの状態を提供するためのインターフェースで、以下のメソッドが用意されている

public interface UserDetails extends Serializable {
 String getUserName();//ユーザ名の返却
 String getPassword();//登録されているパスワードを返却。入力されたパスワードと一致しなかった
                      //場合、DaoAuthenticationProviderはBadCredentialExceptionをスローする
 boolean isEnabled();//有効なユーザかどうか判定。有効の場合trueが返る
             //無効なユーザの場合、DaoAuthenticationProviderはDisabledExceptionをスローする
 boolean isAccountNonLocked();//アカウントのロック状態を返却する。ロックされていない場合true
               //ロックされている場合、DaoAuthenticationProviderはLockedExceptionをスローする
 boolean isAccountNonExpired();//アカウントの有効期限を判定するメソッド。有効の場合はtrue
               //無効の場合、DaoAuthenticationProviderはAccountExpiredExceptionをスローする
 boolean isCredentialNonExpired();//資格情報の有効期限を判定するメソッド。有効の場合はtrue
               //無効の場合、DaoAuthenticationProviderはCredentialExcpiredExceptionをスロー
 Collection<? extends GrantedAuthority> getAuthorities();//ユーザに与えらえれている権限リストを返却する
                  //このメソッドは認可処理の際に利用する
}

で、SpringSecurityでは、UserDetailsの実装クラスとしてUserクラスを提供している

4:DBアクセス関連処理の作成

さて、DBアクセスです

DBアクセス処理を担うDAOクラスからみていきましょう!

package com.soloware.taskapp.domain.dao;

import com.soloware.taskapp.domain.data.LoginUser;
import com.soloware.taskapp.domain.mapper.LoginMapper;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

@Repository
public class LoginUserDao {

    @Autowired
	LoginMapper mapper;

	public LoginUser findUser(String userName) {
        return mapper.findUser(userName);
    }
    
}

データ永続化関連なので@Repositoryアノテーションをつけていて、LoginMapperインターフェースをDIしています。

findUserメソッドで引数に受け取った(フォームから送られてきたもの)ユーザ名を使用して、データを取得します!

LoginMappeerインターフェースがこれ

package com.soloware.taskapp.domain.mapper;

import com.soloware.taskapp.domain.data.LoginUser;

import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface LoginMapper{

    LoginUser findUser(String user_name);

}

何の変哲もないクラスですね。あ、@Mapperアノテーション関連の説明は「SpringBootでMyBatis使ってみた」記事で解説しているのでよかったらみてください。

マッピングファイルがこれ

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.soloware.taskapp.domain.mapper.LoginMapper">
    <select id="findUser" resultType="com.soloware.taskapp.domain.data.LoginUser">
        SELECT * FROM user WHERE user_name = #{user_name} 
     </select>
</mapper>

いいですね。user_name(引数で受け取ったもの)を使ってデータを検索していますl

取得したデータを格納するエンティティクラスはこれ

package com.soloware.taskapp.domain.data;

import javax.validation.constraints.NotNull;

public class LoginUser{
    
    @NotNull
	private int id;

	@NotNull
	private String user_name;

	@NotNull
	private String password;

	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}


	public String getPassword() {
		return password;
	}

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

	/**
	 * @return the user_name
	 */
	public String getUser_name() {
		return user_name;
	}

	/**
	 * @param user_name the user_name to set
	 */
	public void setUser_name(String user_name) {
		this.user_name = user_name;
	}
}

idとユーザ名、パスワードをフィールドにもつクラスです。

最後に、データベースにテーブルとデータを初期登録しておきます。

schema.sqlにテーブル作成文を書き、resources直下に保存します。

DROP TABLE IF EXISTS user;
DROP TABLE IF EXISTS SPRING_SESSION;
DROP TABLE IF EXISTS SPRING_SESSION_ATTRIBUTES;

CREATE TABLE user (
        id INT(11) PRIMARY KEY,
	user_name CHAR(100) ,
	password CHAR(100)
);


CREATE TABLE SPRING_SESSION (
	PRIMARY_ID CHAR(36) NOT NULL,
	SESSION_ID CHAR(36) NOT NULL,
	CREATION_TIME BIGINT NOT NULL,
	LAST_ACCESS_TIME BIGINT NOT NULL,
	MAX_INACTIVE_INTERVAL INT NOT NULL,
	EXPIRY_TIME BIGINT NOT NULL,
	PRINCIPAL_NAME VARCHAR(100),
	CONSTRAINT SPRING_SESSION_PK PRIMARY KEY (PRIMARY_ID)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;
 
CREATE UNIQUE INDEX SPRING_SESSION_IX1 ON SPRING_SESSION (SESSION_ID);
CREATE INDEX SPRING_SESSION_IX2 ON SPRING_SESSION (EXPIRY_TIME);
CREATE INDEX SPRING_SESSION_IX3 ON SPRING_SESSION (PRINCIPAL_NAME);
 
CREATE TABLE SPRING_SESSION_ATTRIBUTES (
	SESSION_PRIMARY_ID CHAR(36) NOT NULL,
	ATTRIBUTE_NAME VARCHAR(200) NOT NULL,
	ATTRIBUTE_BYTES BLOB NOT NULL,
	CONSTRAINT SPRING_SESSION_ATTRIBUTES_PK PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME),
	CONSTRAINT SPRING_SESSION_ATTRIBUTES_FK FOREIGN KEY (SESSION_PRIMARY_ID) REFERENCES SPRING_SESSION(PRIMARY_ID) ON DELETE CASCADE
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;

次に、USERテーブルに認証を許可するユーザ名とパスワードを登録するdata.sqlを同じくresources直下に保存します

INSERT INTO user (id, user_name, password) VALUES(0, 'soloware', 'solo');

以上で、データベース関連の処理は終わりです。

5:リクエストハンドラーの作成

では!リクエストハンドラを作ります

package com.soloware.taskapp.app;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class LoginController {

    @RequestMapping(value = "/hello", method = RequestMethod.POST)
    private String init(Model model) {
	// HttpSessionに情報格納している
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        String userName = auth.getName();
        model.addAttribute("userName", userName);
        return "hello";
 
    }
}

WebConfigSecurityクラスにて認証が成功したら「/hello」にフォワードするため、それを受け取るハンドラーが必要になります

ちなみに、このリクエストハンドラーが呼ばれるということは認証が成功しているということになります。

SpringSecurityのデフォルト実装では、認証済みのユーザの認証情報は、セッションに格納される。セッションに格納された認証情報はリクエストごとにSecurityContextPersistenceFilterクラスによってSecurityContextHolderというクラスに格納され、同一スレッド内であれば、どこからでもアクセスすることができます。

Authentication auth = SecurityContextHolder.getContext().getAuthentication();

この部分で、SecurityContextHolderから認証情報を取得してきます。

6:テンプレート側の作成

最後のテンプレートです。「login.html」「hello.html」の2つ

login.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
</head>
<body>
    <div th:if="${param.logout}">
            <p th:text="#{logout.done}"/>
        </div>
        <div th:if="${param.error}">
            <p th:text="#{login.failure}"/>
        </div>
        
    <form th:action="@{/sign_in}" method="POST">
        <label for="username">USER ID</label>
        <input type="text" name="username" value="" required/><br>
        <label for="password">PASSWORD</label>
        <input type="password" name="password" value="" required/>
        <input type="submit" value="LOGIN" />
    </form>
</body>
</html>

body内1つ目と2つ目のdivタグは、loginパスの後ろにlogoutのパラメータがあれば、1つ目のdivタグを表示し、errorのパラメータがあれば、2つ目のdivタグを表示するというものです。タグ内のtextには、messages.propertiesで設定した内容を出力せています。

各パラメータについては、WebSecurityConfigクラスで設定していましたね!

   @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login") 
                .loginProcessingUrl("/sign_in") 
                .usernameParameter("username") 
                .passwordParameter("password")
                .successForwardUrl("/hello")
                .failureUrl("/login?error")
                .permitAll()
                .and()
            .logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login?logout")
                .permitAll();
    }

これです。?に続くものがパラメータになります。

続く、formタグのリクエスト先が「/sign_in」パスで、このパスは、WebSecurityConfigでのloginProsessingUrlで指定していたパスですね!

ここにリクエストが来ると、認証処理が走ります。

hello.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Sign in</title>
</head>
<body>
    Sign in!
    <p>Hello!!! ログインユーザーは、</p>
	<p th:text="${userName}"></p>

	<form method="post" th:action="@{/logout}">
	     <input type="submit" value="logout">
	</form>
</body>
</html>

この画面が認証処理が無事に成功した場合に表示される画面です。

問題ないですね!

ログアウトボタンを押せば、ログアウト処理が走ってログイン画面に戻るって感じです!

では、動かしてみましょう!

ログイン画面を表示し、data.sqlで登録しておいたユーザ名とパスワードでアクセスします。

無事にログインができました!では、ログアウトしてみます!

OKですね!

最後に、DBに登録していないユーザ名とパスワードでアクセスしてみます!

無事に弾かれました!

以上で、SpringBootでログイン機能作ってみたでした! 

ちなみに、ですね。

今回の記事は、ほぼほぼこの人の完コピになってしまってます。

参考サイト:https://takaxtech.com/2019/05/29/article311/

この人、尊敬です!私もまだまだ精進していきたいと思います!

BYE!

    タイトルとURLをコピーしました