前回、Spring BootでSpring Security機能を有効化する方法について見てきました。
次は、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
を使います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
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の実装クラスは作成しません。
インターフェースを見ればメソッド名からどういう実装をすべきか判断することができると思います。ユーザ名、パスワード、ロール、アカウント有効無効、ロック状態や有効期限などの情報を返すクラスを実装すれば良いことが分かります。
1 2 3 4 5 6 7 8 9 |
public interface UserDetails extends Serializable { Collection<? extends GrantedAuthority> getAuthorities(); String getPassword(); String getUsername(); boolean isAccountNonExpired(); boolean isAccountNonLocked(); boolean isCredentialsNonExpired(); boolean isEnabled(); } |
参考
- org.springframework.security.core.userdetails.UserDetails
- org.springframework.security.core.userdetails.User
UserServiceDetailsの実装
ユーザ情報に関する操作機能を提供します。メイン機能のインターフェースは以下のようになっており、ユーザ名を引数で受け取り、ユーザ情報を返す仕様となっています。
今回の例では、UserDetails
同様にSpringにあるデフォルト実装を利用することとします。今回はデータベース認証機能の実装が目的なので、最初のクラス図で挙げたorg.springframework.security.provisioning.JdbcUserDetailsManager
を使うことにします。このクラスは、JdbcTemplateを使ったデータアクセス機能を提供する抽象クラスJdbcDaoSupport
を継承していています(具体的にはJdbcDaoSupport
の具象クラスであるJdbcDaoImpl
を継承)。また、ユーザに関する基本的なCRUD機能の仕様であるUserDetailsManager
インターフェースを実装しています。従って、JdbcUserDetailsManager
クラスを使うことで、ユーザに関する基本的なCRUD機能を享受することができます。
1 2 3 |
public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; } |
参考
- org.springframework.security.core.userdetails.UserDetailsService
- org.springframework.security.provisioning.UserDetailsManager
- org.springframework.security.provisioning.JdbcUserDetailsManager
データベースコンフィグレーション
上記までで、ユーザ情報やユーザ情報操作の基本的な操作を準備することができました。と言っても、デフォルト実装を使用するという方針を定めただけでプログラムは書いていません。
続いて、spring-jdbc機能の設定が必要になりますが、コンフィグレーションはSpring BootのAutoConfigurationが行なってくれます。DataSource
などのBean定義は基本的には不要で、使用するデータベースの依存関係の追加やapplication.properties
に必要な設定を記述するだけで、spring-jdbcが提供してくれる機能をインジェクションして使用できるようになっています。
application.properites
データベースに関連する設定は、最後のセクションです。起動時にスキーマ作成と初期データの投入を行うための設定及び、H2 Consoleを使うための設定をしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
#---------------------------- # 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つを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
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
クラスが想定しているクエリに合わせて、以下のように定義しています。(アカウントのロックや期限などの情報は省略しています)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
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) ); |
1 2 |
insert into users (username, password, enabled) values ('admin', '{bcrypt}$2a$10$vC.r53zKYPwEXplBYH3mxuZP52r2u3udRcEg9yTUmwYE5yjmoUXyG', true); insert into authorities (username, authority) values ('admin', 'ROLE_ADMIN'); |
セキュリティコンフィグレーション
続いて、セキュリティ機能の設定を確認しておきます。今回は、簡易なユーザ管理機能を作るという目的のため、/user
というパスに認証・認可を要求するように設定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
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); } } |
フォーム
ユーザ名とパスワードと受け取るだけの単純なクラスとします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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
に委譲するようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
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); } } |
コントローラとビュー
最後に、コントローラとビューを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
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"; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
<%@ 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> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<%@ 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にアクセスすると、ログインフォームへリダイレクトされます。
このサンプルプログラムでは、admin/adminでログインできます。ログインすると以下のような簡素な画面が表示されます。
H2 Console画面も以下のように表示できます。
まとめ
- Spring Bootでは、AutoConfiguration機能のおかけで、
application.properties
で最低限の設定をするだけで、spring-jdbc
の基本的なコンフィグレーションが行われ、簡単にデータアクセス機能を利用したspring-security
のデータベース認証処理を行なうことができます。 spring-boot-starter-jdbc
では、デフォルトのデータソースとしてHikariDataSource
が使われます。UserDetailsService
の実装クラスであるJdbcUserDetailsManager
を使えば、ユーザに関する基本的なCRUD操作が行えます。PasswordEncoder
は、デフォルトでBCryptPasswordEncoder
が使われます。
今回使用したサンプルプログラムは以下です。
参考リンク
- https://docs.spring.io/spring-boot/docs/2.0.4.RELEASE/reference/htmlsingle/
- https://docs.spring.io/spring/docs/current/spring-framework-reference/data-access.html
- https://github.com/spring-projects/spring-boot/tree/master/spring-boot-samples
翔泳社
売り上げランキング: 37,650
秀和システム
売り上げランキング: 7,583
コメント