Spring BootでSpring Security機能を使う ー データベースを使った認証

前回、Spring BootでSpring Security機能を有効化する方法について見てきました。

https://debug-life.net/entry/3189

次は、Spring BootでSpring Securityの認証機能の1つであるデータベースを使った認証の仕組みについて見ていきます。

Spring Securityの認証処理の仕組み

Webアプリケーションのセキュリティ機能を提供してくれているspring-security-webでは、サーブレットフィルタの仕組みを利用し、認証機能が実現されていることを見てきました。では、実際にフィルタからどのように認証処理が行われているのでしょうか?

仕組みを俯瞰するため、まずは以下に主要なクラスの関連図(概要のため省略あり)をまとめてみます。

概要をまとめると、以下のようになっているようです。

  • 認証処理は、AuthenticationManagerが受け付ける。AuthenticationManagerはインターフェースであり、実装クラスとしてProviderManagerがある。
  • ProviderManagerは、複数の認証プロバイダAuthenticationProvider(実際に認証処理を担当する)を管理しており、認証の問い合わせを受けると、各認証プロバイダに認証処理を委譲する。
  • 認証に成功すると、Authenticationオブジェクトが返される。Authenticationはインターフェースであり、実装クラスはxxAuthenticationTokenのように命名されている。例えば、フォーム認証ではUsernamePasswordAuthenticationTokenが使われている。

と、このように認証マネージャ(実装クラスProviderManager)が複数の認証プロバイダ(具体的な認証処理を行なうクラス達)に認証処理を委譲するように抽象化されていることが分かります。そして、spring-securityに限らずですが、この抽象化がユーザ独自機能を含めたカスタマイズを可能にしています。

Spring Bootでデータベース認証を実装してみる

Spring Bootの大きな特徴は、様々な設定を自動化しすぐに所望する機能を使用することができるような仕組みの提供(autoconfiguration)でした。Spring Bootはデータベース機能(spring-jdbc)を使用するためのコンフィグレーションを提供してくれているため、それを利用することでデータベース認証機能を用意に実装することができます。

ここでは、spring-jdbcを使って組み込みデータベースでの簡単なユーザ管理機能を実装してみたいと思います。

依存関係の定義

今回のサンプルでは、組み込みデータベースのH2を使うこととします。また、データアクセス機能には、spring-jdbcを使います。

buildscript {
    ext {
        springBootVersion = '2.0.4.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile('org.springframework.boot:spring-boot-starter-security')
    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.springframework.boot:spring-boot-starter-jdbc')
    runtime('com.h2database:h2')
    runtime('org.springframework.boot:spring-boot-devtools')
    // SpringSecurityのtablibを使う
    compile('org.springframework.security:spring-security-taglibs')
    // JSPを使う設定
    compile('org.apache.tomcat.embed:tomcat-embed-jasper')
    // jstlを使う設定
    compile('javax.servlet:jstl')
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.springframework.security:spring-security-test')
}

UserDetailsの実装

まずは、ユーザ情報を提供する機能について見ていきます。

インターフェースは以下のようになっていますが、今回は既にSpringにある実装のorg.springframework.security.core.userdetails.Userクラスで代用することとします。従って、ここでは簡単のためUserDetailsの実装クラスは作成しません。

インターフェースを見ればメソッド名からどういう実装をすべきか判断することができると思います。ユーザ名、パスワード、ロール、アカウント有効無効、ロック状態や有効期限などの情報を返すクラスを実装すれば良いことが分かります。

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    String getPassword();
    String getUsername();
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialsNonExpired();
    boolean isEnabled();
}

参考 

UserServiceDetailsの実装

ユーザ情報に関する操作機能を提供します。メイン機能のインターフェースは以下のようになっており、ユーザ名を引数で受け取り、ユーザ情報を返す仕様となっています。

今回の例では、UserDetails同様にSpringにあるデフォルト実装を利用することとします。今回はデータベース認証機能の実装が目的なので、最初のクラス図で挙げたorg.springframework.security.provisioning.JdbcUserDetailsManagerを使うことにします。このクラスは、JdbcTemplateを使ったデータアクセス機能を提供する抽象クラスJdbcDaoSupportを継承していています(具体的にはJdbcDaoSupportの具象クラスであるJdbcDaoImplを継承)。また、ユーザに関する基本的なCRUD機能の仕様であるUserDetailsManagerインターフェースを実装しています。従って、JdbcUserDetailsManagerクラスを使うことで、ユーザに関する基本的なCRUD機能を享受することができます。

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

参考 

データベースコンフィグレーション

上記までで、ユーザ情報やユーザ情報操作の基本的な操作を準備することができました。と言っても、デフォルト実装を使用するという方針を定めただけでプログラムは書いていません。

続いて、spring-jdbc機能の設定が必要になりますが、コンフィグレーションはSpring BootのAutoConfigurationが行なってくれます。DataSourceなどのBean定義は基本的には不要で、使用するデータベースの依存関係の追加やapplication.propertiesに必要な設定を記述するだけで、spring-jdbcが提供してくれる機能をインジェクションして使用できるようになっています。

application.properites

データベースに関連する設定は、最後のセクションです。起動時にスキーマ作成と初期データの投入を行うための設定及び、H2 Consoleを使うための設定をしています。

#----------------------------
# JSP Setting
#----------------------------
# templates are located in 'src/main/webapp/WEB-INF/jsp'.

spring.mvc.view.prefix=/WEB-INF/jsp/
spring.mvc.view.suffix=.jsp

#----------------------------
# Spring Security User properties
#----------------------------
# These properties are refered in authentication process.
#spring.security.user.name=admin
#spring.security.user.password=admin

#----------------------------
# Logging Setting
#----------------------------
logging.level.root=INFO
logging.level.org.springframework.web=DEBUG
logging.level.sample.web.form.security=DEBUG

#----------------------------
# Database
#----------------------------

# MySQL
#spring.datasource.driver-class-name=com.mysql.jdbc.Driver
#spring.datasource.url=jdbc:mysql://localhost/mydb
#spring.datasource.username=dbuser
#spring.datasource.password=dbpass

# PostgreSQL
#spring.datasource.driver-class-name=org.postgresql.Driver
#spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
#spring.datasource.username=postgres
#spring.datasource.password=postgres

# スキーマ作成と初期データの投入を行なう
spring.datasource.schema=classpath:/META-INF/sql/schema.sql
spring.datasource.data=classpath:/META-INF/sql/data.sql
spring.datasource.sql-script-encoding=utf-8
# H2-consoleを使う
spring.h2.console.enabled=true

# 以下のurlとdriverClassNameは、デフォルトで設定されるのでここではコメントにしている
#spring.datasource.url=jdbc:h2:mem:AZ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE
#spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

データベースコンフィグレーション

以下の2つを定義します。

package sample.web.database.security;

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.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;


@Configuration
public class DatabaseConfiguration {

    @Autowired
    DataSource dataSource;
    
    @Bean
    public UserDetailsManager userDetailsManager() {
        return new JdbcUserDetailsManager(dataSource);
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        // デフォルトはbcrypt
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

上記例では、UserDetailsServiceとしてJdbcUserDetailsManagerを定義しています。また、PasswordEncoderも同様に使いたいのでここで定義していますが、PasswordEncoderFactories.createDelegatingPasswordEncoder()で直接PasswordEncoderの実装クラスを指定していません。プリフィックス型({xxx})がある場合、別のサポートする実装クラスに照合処理を委譲できるようにしています。例えば、{noop}xxxxでは、NoOpPasswordEncoderが使われます(ただし、NoOpPasswordEncoderは@Deprecatedです、実際のサービスでは別のセキュアな実装を使います)。

なお、spring-boot-starter-jdbcでは、デフォルトでHikariDataSourceが使われます。

スキーマとデータ

起動時に定義する内容です。JdbcUserDetailsManagerクラスが想定しているクエリに合わせて、以下のように定義しています。(アカウントのロックや期限などの情報は省略しています)

create table users (
  userid int primary key AUTO_INCREMENT,
  username varchar(64) not null,
  password varchar(128) not null,
  enabled boolean not null,
  CONSTRAINT uq_username UNIQUE (username)
);

create table authorities (
  username varchar(64) not null,
  authority varchar(32) not null,
  PRIMARY KEY (username, authority)
);

create table groups (
  group_id int primary key AUTO_INCREMENT,
  grpname varchar(64) not null,
);

create table group_members (
  group_id int,
  username varchar(64),
  FOREIGN KEY (group_id) REFERENCES groups (group_id),
  FOREIGN KEY (username) REFERENCES users (username),
  PRIMARY KEY (group_id, username)
);
  
insert into users (username, password, enabled) values ('admin', '{bcrypt}$2a$10$vC.r53zKYPwEXplBYH3mxuZP52r2u3udRcEg9yTUmwYE5yjmoUXyG', true);
insert into authorities (username, authority) values ('admin', 'ROLE_ADMIN');

セキュリティコンフィグレーション

続いて、セキュリティ機能の設定を確認しておきます。今回は、簡易なユーザ管理機能を作るという目的のため、/userというパスに認証・認可を要求するように設定します。

package sample.web.database.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.UserDetailsManager;

@SpringBootApplication
public class SpringSecurityDatabaseAuthenticationApplication {

    @Configuration
    static class WebSecurityConfig extends WebSecurityConfigurerAdapter {

        @Autowired
        UserDetailsManager userDetailsManager;
        
        @Autowired
        PasswordEncoder passwordEncoder;
        
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            // UserDetailServiceとPasswordEncorderを指定
            auth.userDetailsService(userDetailsManager).passwordEncoder(passwordEncoder);
        }
        
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            // Configure a new SecurityChainFilter.
            // Calling of authorizeRequests() sets AnyRequestMatcher to requestMatchar. 
            http
                .authorizeRequests()
                    // ROLE ADMINを要求
                    .antMatchers("/user/**", "/h2-console/**").hasRole("ADMIN").and()
                    // Form認証を要求
                    .formLogin().defaultSuccessUrl("/user/home").and()
                    // 以下は、H2 Consoleにアクセスするために必要
                    .csrf().ignoringAntMatchers("/h2-console/**").and()
                    .headers().frameOptions().sameOrigin();
        }

        @Override
        public void configure(WebSecurity web) throws Exception {
            // Configure a new SecurityChainFilter
            web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
        }

    }

    public static void main(String[] args) {
        System.out.println(new BCryptPasswordEncoder().encode("admin")); // パスワード確認
        SpringApplication.run(SpringSecurityDatabaseAuthenticationApplication.class, args);
    }
}

フォーム

ユーザ名とパスワードと受け取るだけの単純なクラスとします。

package sample.web.database.security;

import java.io.Serializable;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import lombok.Getter;
import lombok.Setter;

public class UserForm implements Serializable {

    private static final long serialVersionUID = 1L;

    @NotNull
    @Size(min = 3, max = 64)
    @Setter @Getter
    private String username;
    
    @NotNull
    @Setter @Getter
    @Size(min = 6, max = 128)
    private String password;
    
}

リポジトリクラス

データアクセスのためのクラスを作成します。ここでは、UserRepositoryとします。リポジトリパターンではインターフェースを定義するかと思いますが、今回は簡単のためインターフェース定義は省略します。

以下サンプルでは、CRUD機能はUserDetailsMangerに委譲するようにしています。

package sample.web.database.security;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
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.jdbc.JdbcDaoImpl;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.stereotype.Repository;

@Repository
public class UserRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    // 本サンプルでは、データアクセスはUserDetailsManagerに任せる
    @Autowired
    private UserDetailsManager userDetailsManager;

    public List<UserDetails> findAll() {
        return jdbcTemplate.query("SELECT * FROM users ORDER BY userid", new RowMapper<UserDetails>() {
            // ROLEの取得処理
            @Override
            public User mapRow(ResultSet rs, int rowNum) throws SQLException {
                String username = rs.getString("username");
                List<GrantedAuthority> authorities = jdbcTemplate.query(JdbcDaoImpl.DEF_AUTHORITIES_BY_USERNAME_QUERY,
                        new String[] { username }, new RowMapper<GrantedAuthority>() {
                            @Override
                            public GrantedAuthority mapRow(ResultSet rs, int rowNum) throws SQLException {
                                String roleName = rs.getString(2);
                                return new SimpleGrantedAuthority(roleName);
                            }
                        });

                return new User(rs.getString("username"), rs.getString("password"), authorities);
            }
        });
    }

    public void createUser(final UserDetails user) {
        userDetailsManager.createUser(user);
    }

    public void updateUser(final UserDetails user) {
        userDetailsManager.createUser(user);
    }

    public void deleteUser(String username) {
        userDetailsManager.deleteUser(username);
    }

    public boolean userExists(String username) {
        return userDetailsManager.userExists(username);
    }
}

コントローラとビュー

最後に、コントローラとビューを作成します。

package sample.web.database.security;

import java.util.List;

import javax.validation.constraints.NotNull;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Controller
@RequestMapping("/user")
public class UserController {

    @Autowired
    UserRepository userRepository;
    
    @Autowired
    PasswordEncoder passwordEncorder;

    @RequestMapping(path = "home")
    public String home(Model model) {
        // ユーザ一覧取得
        List<UserDetails> users = userRepository.findAll();
        model.addAttribute(users);
        return "user/home";
    }

    @RequestMapping(path = "edit")
    public String editUser(Model model) {
        model.addAttribute(new UserForm());
        return "user/edit";
    }

    @RequestMapping(path = "del/{username}")
    public String delUser(
            @AuthenticationPrincipal UserDetails userDetails,
            @PathVariable @NotNull String username,
            RedirectAttributes redirectAttributes,
            Model model) {
        // adminは削除させない
        if (username.equals("admin")) {
            redirectAttributes.addFlashAttribute("message", "invalid request");
            return "redirect:/user/home?error";
        }
        
        boolean isAdmin = userDetails.getAuthorities().stream()
                .anyMatch(auth -> "ROLE_ADMIN".equals(auth.getAuthority()));
        // ADMIN権限が必要
        if (!isAdmin) {
            redirectAttributes.addFlashAttribute("message", "invalid operation");
            return "redirect:/user/home?error";
        }
        
        if (userRepository.userExists(username)) {
            userRepository.deleteUser(username);
            redirectAttributes.addFlashAttribute("message", "user deleted successfully");
        } else {
            redirectAttributes.addFlashAttribute("message", "user does not exist");
        }
        return "redirect:/user/home?complete";
    }
    
    @RequestMapping(path = "new", method = RequestMethod.POST)
    public String newUser(@Validated UserForm form,
            BindingResult result,
            Model model) {
        if (result.hasErrors()) {
            return "user/edit";
        }
        // 既に登録済みか?
        if (userRepository.userExists(form.getUsername())) {
            result.reject("user.exist.err", "user exists");
            return "user/edit";
        }
        
        UserDetails user = new User(form.getUsername(),
                passwordEncorder.encode(form.getPassword()),
                AuthorityUtils.createAuthorityList("ROLE_USER"));
        userRepository.createUser(user);
        
        model.addAttribute("user", user);
        
        return "redirect:/user/home?complete";
    }

}
<%@ include file="../common.jsp"%>
<!DOCTYPE html>
<html>
<head>
<title>Dashboard</title>
</head>
<body>
    <sec:authentication property="principal.username" var="username" />

    <form action="/logout" method="post">
        Hello,
        <c:out value="${username}" />
        <c:if test="${!empty message}">
            <p style="color:red;"><c:out value="${message}" /></p>
        </c:if>
    
        <sec:csrfInput />
        <button>Log Out</button>
    </form>

    <h3>Users</h3>
    <table border="1" style="width:300px;">
        <thead>
            <tr>
                <th>User</th>
                <th>Action</th>
            </tr>
        </thead>
        <tbody>
            <c:forEach var="u" items="${userList}">
                <tr>
                    <spring:url var="url" value="/user/del/{username}"><spring:param name="username" value="${u.username}"/></spring:url>
                    <td width="50%"><c:out value="${u.username}"/></td>
                    <td width="50%"><a href="${url}">Delete</a></td>
                </tr>
            </c:forEach>
        </tbody>
    </table>
    
    <p><a href="/user/edit">New User</a> | <a href="/h2-console">H2 Console</a></p>

</body>
</html>
<%@ include file="../common.jsp"%>
<!DOCTYPE html>
<html>
<head>
<title>Edit User</title>
</head>
<body>

    <spring:hasBindErrors name="userForm">
        <c:forEach var="error" items="${errors.allErrors}">
            <span style="color:red;"><b><spring:message message="${error}" /></b></span>
            <br />
        </c:forEach>
    </spring:hasBindErrors>

    <form:form modelAttribute="userForm" action="/user/new">
        <p>
            <label for="username">Username</label>
            <form:input path="username" />
        </p>
        <p>
            <label for="password">Password</label>
            <form:password path="password" />
        </p>
        <button type="submit" class="btn">Create User</button>
    </form:form>
</body>
</html>

ログインフォームは、Spring Securityのデフォルトの機能を使用しています。

動作画面

以下のURLにアクセスすると、ログインフォームへリダイレクトされます。

   http://localhost:8080/user/

このサンプルプログラムでは、admin/adminでログインできます。ログインすると以下のような簡素な画面が表示されます。

H2 Console画面も以下のように表示できます。

まとめ

  • Spring Bootでは、AutoConfiguration機能のおかけで、application.propertiesで最低限の設定をするだけで、spring-jdbcの基本的なコンフィグレーションが行われ、簡単にデータアクセス機能を利用したspring-securityのデータベース認証処理を行なうことができます。
  • spring-boot-starter-jdbcでは、デフォルトのデータソースとしてHikariDataSourceが使われます。
  • UserDetailsServiceの実装クラスであるJdbcUserDetailsManagerを使えば、ユーザに関する基本的なCRUD操作が行えます。
  • PasswordEncoderは、デフォルトでBCryptPasswordEncoderが使われます。

今回使用したサンプルプログラムは以下です。

https://github.com/moritoru81/spring-boot-misc/tree/master/spring-security-database-authentication

参考リンク

 

 

Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発
株式会社NTTデータ
翔泳社
売り上げランキング: 37,650

 

Spring Boot 2 プログラミング入門
掌田津耶乃
秀和システム
売り上げランキング: 7,583

byebyehaikikyou

日記やIT系関連のネタ、WordPressに関することなど様々な事柄を書き付けた雑記です。ITエンジニア経験があるのでプログラミングに関することなどが多いです。

シェアする

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

コメントする

Translate »