今日の役に立たない一言 - Today’s Trifle! -

古い記事ではさまざまなテーマを書いていますが、2007年以降はプログラミング関連の話がほとんどです。

Spring Boot でJDBCを使ってユーザー登録・ユーザー認証する方法

まずは公式に従う。

https://spring.io/guides/gs/securing-web/

pom.xmljpaとsecurityを追加。

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

DBは、HSQLDBを使用。
appliaction.properties は以下の通り。

spring.datasource.url=jdbc:hsqldb:hsql://localhost/auth
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.hsqldb.jdbc.JDBCDriver
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.HSQLDialect
spring.jpa.hibernate.ddl-auto=update

トップページを(index.html)を作成。

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>トップページ</title>
</head>
<body>
    <h1>トップページ</h1>
    <a href="/mypage">マイページ</a>
</body>
</html>

認証が必要なページ(mypage.html)を作成。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>login</title>
</head>
<body>
    <h1>マイページ</h1>
    <form th:action="@{/logout}" method="post">
        <input type="submit" value="ログアウト" />
    </form>
</body>
</html>

ログイン画面(login.html)を作成。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>login</title>
</head>
<body>
    <div th:if="${param.error}">
        エラー: ユーザ名・パスワードが違います。
    </div>
    <form th:action="@{/login}" method="post">
        <div><label>ユーザ名: <input type="text" name="username"/> </label></div>
        <div><label>パスワード: <input type="password" name="password"/> </label></div>
        <div><input type="submit" value="ログイン"/></div>
    </form>
    <div><a href="/newuser">新規ユーザー登録</a></div>
    <a href="/">戻る</a>
</body>
</html>

新規ユーザー登録画面(newuser.html)を作成。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <title>新規ユーザー登録</title>
</head>
<body>
    <h1>新規ユーザー登録</h1>
    <form th:action="@{/newuser}" method="post">
        <div><label>ユーザ名: <input type="text" name="username"/> </label></div>
        <div><label>パスワード: <input type="password" name="password"/> </label></div>
        <div><label>パスワード再入力: <input type="password" name="password2"/> </label></div>
        <div><input type="submit" value="登録"/></div>
    </form>
    <a href="/">戻る</a>
</body>
</html>

コントローラを作成。

package hoge;
 
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
 
@Controller
public class MyController {
    @RequestMapping(value = "/")
    public String index() {
        return "index";
    }
 
    @RequestMapping(value = "/login")
    public String login() {
        return "login";
    }
 
    @RequestMapping(value = "/newuser")
    public String newuser() {
        return "newuser";
    }
 
    @RequestMapping(value = "/mypage")
    public String top() {
        return "mypage";
    }
}

この状態でSpring Bootアプリを起動すると、認証の制限がかかってないので、各ページを自由に行き来できる。

/ と /newuser は誰でもアクセス可能。
/mypage は、ログインしないとアクセスできないようにする。

package hoge;
 
import org.springframework.context.annotation.Configuration;
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;
 
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/", "/newuser").permitAll()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .loginPage("/login")
            .permitAll()
            .and()
            .logout()
            .permitAll();
    }

上記のWebSecurityConfig を作ってから再起動すると、/ と /newuser にはアクセス可能。/mypage にアクセスしようとすると、/login に飛ばされる。

JDBCで認証できるようにする。

公式にはちらっとJDBC認証の方法がある。

https://docs.spring.io/spring-security/site/docs/current/reference/html/jc.html

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        UserBuilder users = User.withDefaultPasswordEncoder();
        auth
            .jdbcAuthentication()
                .dataSource(dataSource)
                .withDefaultSchema()
                .withUser(users.username("user").password("password").roles("USER"));
    }

しかし、このコードだと一発目は動作するけど、二回目からは例外が発生してSpring Boot アプリが起動しなくなる。

withDefaultSchema()とwithUser()あたりが例外の原因だった。
以下の1行だけにすれば二回目からも問題なく動作する。

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.jdbcAuthentication().dataSource(dataSource);
    }

この先の実装方法がわからなくていろいろぐぐってみた。
が、公式のドキュメントが少ないし、ぐぐってみても割と複雑なコードで実装してる人が多くて困った。

なんとなく、もっと簡単なコードで実装できそうな気がすると思ったので適当にコード書いたら動いちゃった。

新規ユーザー登録できるようにする。

とりあえずJdbcUserDetailsManager をコントローラにDIしてみる。

    @Autowired
    private JdbcUserDetailsManager userManager;


/newuser の POST でユーザー登録する処理を書く。
JdbcUserDetailsManagerにcreateUser()というメソッドがあったので、これ使えばできるんじゃないかなと。

上記の公式の説明でInMemoryUserDetailsManagerでもそうしてたし。
スーパークラスが同じだからたぶんいけるはず!

    @RequestMapping(value = "/newuser", method = RequestMethod.GET)
    public String newuser() {
        return "newuser";
    }
 
    @RequestMapping(value = "/newuser", method = RequestMethod.POST)
    public String register(@RequestParam("username") String username,
                           @RequestParam("password") String password) {
        UserBuilder users = User.withDefaultPasswordEncoder();
        userManager.createUser(users.username(username).password(password).roles("USER").build());
        return "login";
    }

User.withDefaultPasswordEncoder()でdeprecatedの警告が出るので、気になる人は適当なPasswordEncoderを設定してね。

これで起動しようとしたら、JdbcUserDetailsManagerをDIしたいけど、定義がないからできないよって怒られる。

JdbcUserDetailsManager と DataSource をWebSecurityConfigに用意する。
よくわからんけど、インスタンス生成してDataSourceだけ設定しとけば動くんじゃね?的な。

    @Autowired
    private DataSource dataSource;

    @Bean
    public JdbcUserDetailsManager jdbcUserDetailsManager() throws Exception {
        JdbcUserDetailsManager jdbcUserDetailsManager = new JdbcUserDetailsManager();
        jdbcUserDetailsManager.setDataSource(dataSource);
        return jdbcUserDetailsManager;
    }

問題なく起動できた。

でも、DBにユーザーが存在しないのでログインできない。

/newuser から新規ユーザー登録する。
新規ユーザー登録後は、USERSテーブルに登録されているのが確認できる。パスワードも暗号化されてる。
というわけで、ユーザー登録は成功。

登録したユーザーでログインすると、/mypage に行けるようになった。
ユーザー認証も成功。

登録済みのユーザー名で再び登録しようとすると例外が発生する。
そのエラーハンドリングと、パスワード再入力のチェックを追加する。

@RequestMapping(value = "/newuser", method = RequestMethod.POST)
public ModelAndView register(
        ModelAndView mav,
        @RequestParam("username") String username,
        @RequestParam("password") String password,
        @RequestParam("password2") String password2) {
    if (!password.equals(password2)) {
        mav.setViewName("newuser");
        mav.addObject("error", "パスワードが一致していません。");
        return mav;
    }
    UserBuilder users = User.withDefaultPasswordEncoder();
    try {
        userManager.createUser(users.username(username).password(password).roles("USER").build());
        mav.setViewName("login");
    } catch (Exception e) {
        mav.setViewName("newuser");
        mav.addObject("error", "ユーザー名は使用できません。:" + username);
    }
    return mav;
}

これでJDBCを使用したユーザー登録とユーザー認証の動作ができるようになった。


WebSecurityConfig の全体。

package hoge;
 
import javax.sql.DataSource;
 
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.provisioning.JdbcUserDetailsManager;
 
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{
    @Autowired
    private DataSource dataSource;
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/", "/newuser").permitAll()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .loginPage("/login")
            .permitAll()
            .and()
            .logout()
            .permitAll();
    }
 
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.jdbcAuthentication()
            .dataSource(dataSource);
    }
 
    @Bean
    public JdbcUserDetailsManager jdbcUserDetailsManager() throws Exception {
        JdbcUserDetailsManager jdbcUserDetailsManager = new JdbcUserDetailsManager();
        jdbcUserDetailsManager.setDataSource(dataSource);
        return jdbcUserDetailsManager;
    }
}

コントローラの全体。

package hoge;
 
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.User.UserBuilder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
 
@Controller
public class MyController {
 
    @Autowired
    private JdbcUserDetailsManager userManager;
 
    @RequestMapping(value = "/")
    public String index() {
        return "index";
    }
 
    @RequestMapping(value = "/login")
    public String login() {
        return "login";
    }
 
    @RequestMapping(value = "/newuser", method = RequestMethod.GET)
    public String newuser() {
        return "newuser";
    }
 
    @RequestMapping(value = "/newuser", method = RequestMethod.POST)
    public ModelAndView register(
            ModelAndView mav,
            @RequestParam("username") String username,
            @RequestParam("password") String password,
            @RequestParam("password2") String password2) {
        if (!password.equals(password2)) {
            mav.setViewName("newuser");
            mav.addObject("error", "パスワードが一致していません。");
            return mav;
        }
        UserBuilder users = User.withDefaultPasswordEncoder();
        try {
            userManager.createUser(users.username(username).password(password).roles("USER").build());
            mav.setViewName("login");
        } catch (Exception e) {
            mav.setViewName("newuser");
            mav.addObject("error", "ユーザー名は使用できません。:" + username);
        }
        return mav;
    }
 
    @RequestMapping(value = "/mypage")
    public String mypage() {
        return "mypage";
    }
}