1. はじめに
Spring Securityで一般利用者用と管理者用でログイン画面(処理)を分けたいという要望があったため、ログイン画面を複数定義する方法について説明したいと思います。
1.1. 検証環境
- Java 1.8.0_131
- Spring Framework 4.3.5
- Spring MVC 4.3.5
- Spring Security 4.1.4
2. 実現方法、ポイント
- 認証情報(UserDetails)および認証処理(UserDetailsService)をそれぞれ用意する。
- ログイン画面や認証後に表示する画面(たとえばメニュー画面等)をそれぞれ用意する。
- Bean定義において、それぞれに対応する
<sec:authentication-manager>
を用意する。 - Bean定義において、
<sec:http>
要素のpattern
属性に適用範囲を設定する。 - Bean定義において、
<sec:http>
要素のauthentication-manager-ref
属性に、3で定義したauthentication-manager
のBeanIdを設定する。
3. 検証用のサンプルコード
参考までに、検証用に作成したサンプルコードを記載します。
3.1. 概要
検証アプリをゼロから作成するのは手間が掛かるため、公開されているTERASOLUNA5.xのデモアプリである「Tour Reservation Sample Application」に、もう一つのログイン画面(処理)を追加したいと思います。
ここからデモアプリ(terasoluna-tourreservation-mybatis3-5.3.0.RELEASE.zip
)をダウンロードして任意のディレクトリに展開してください。
デモアプリには顧客用のログイン画面(処理)が実装されています。ここに社員用のログイン画面(処理)を追加していきます。追加するファイルとその概要については以下の表を参照ください。
なお、今回修正するSpringSecurityの設定を行っているBean定義ファイルはterasoluna-tourreservation-web/src/main/resources/META-INF/spring/spring-security.xml
になります。
項番 | 認証処理1 (元々実装されているもの) |
認証処理2 (今回追加するもの) |
説明 |
---|---|---|---|
1 | ReservationUserDetails.java |
EmployeeUserDetails.java |
・認証情報(UserDetails) ・ユーザ情報のクラスがCustomerとEmployeeで異なる。 |
2 | ReservationUserDetailsService.java |
EmployeeUserDetailsService.java |
・認証処理(UserDetailsService) ・今回追加する方はDBアクセスを行わず、ダミーのEmployeeを返す。 ・パスワードも平文とする。 |
3 | WEB-INF/views/login/login.jsp |
WEB-INF/views/login/employeeForm.jsp |
・ログイン画面 |
4 | WEB-INF/views/menu/menu.jsp |
WEB-INF/views/menu/employeeMenu.jsp |
・メニュー画面 ・今回追加する方はログアウトボタンだけがあるメニューとする。 |
5 | EmployeeLoginController.java |
・ログイン画面とメニュー画面を表示するためのController ・認証時のPOSTに対応するため、GETとPOSTの両方をハンドリングする。 |
3.2. サンプルコード
package org.terasoluna.tourreservation.domain.service.userdetails;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.terasoluna.tourreservation.domain.model.Employee;
public class EmployeeUserDetails extends User {
private static final long serialVersionUID = 1L;
private final Employee employee;
public EmployeeUserDetails(Employee employee,
Collection<? extends GrantedAuthority> authorities) {
super(employee.getStaffCode(), employee.getStaffPass(), true, true,
true, true, authorities);
this.employee = employee;
}
public Employee getEmployee() {
return employee;
}
@Override
public String toString() {
return "EmployeeUserDetails [employee=" + employee + "]";
}
}
package org.terasoluna.tourreservation.domain.service.userdetails;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.terasoluna.tourreservation.domain.model.Employee;
public class EmployeeUserDetailsService implements UserDetailsService {
private static final Logger LOGGER = LoggerFactory
.getLogger(EmployeeUserDetailsService.class);
private final String DUMMY_PASS = "dummyPass";
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
Employee employee = new Employee();
employee.setStaffCode(username);
employee.setStaffKana("シケン タロウ");
employee.setStaffName("試験 太郎");
employee.setStaffPass(DUMMY_PASS);
EmployeeUserDetails userDetails = new EmployeeUserDetails(employee,
AuthorityUtils.createAuthorityList("ROLE_EMPLOYEE"));
LOGGER.debug("userDetails={}", userDetails);
return userDetails;
}
}
ログイン時に入力する社員IDをusername、"dummyPass"
をpasswordとするEmployeeUserDetails
を返すデモ用のUserDetailsService
とします。
ロールは一律にROLE_EMPLOYEE
とします。
package org.terasoluna.tourreservation.app.login;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller
@RequestMapping("employee")
public class EmployeeLoginController {
@RequestMapping(value = "login", method = {RequestMethod.GET, RequestMethod.POST})
public String loginForm() {
return "login/employeeForm";
}
@RequestMapping(method = {RequestMethod.GET, RequestMethod.POST})
public String menu() {
return "menu/employeeMenu";
}
}
今回作成したログイン画面(employeeForm.jsp
)とメニュー画面(employeeMenu
)を表示するだけのController
です。
認証処理のPOST
でもアクセスできるように、GET
とPOST
の両方でマッピングします。
<div class="container">
<h2>社員用のログイン画面</h2>
<c:if test="${param.containsKey('error')}">
<span id="loginError"> ログインに失敗しました。社員IDとパスワードを確認してください。 </span>
</c:if>
<form:form action="${pageContext.request.contextPath}/employee/authenticate"
method="post">
<div>
社員ID: <input type="text" name="username" />
</div>
<div>
パスワード: <input type="password" name="password" />
</div>
<div>
<input type="submit" value="ログイン" />
</div>
</form:form>
</div>
<div class="container">
<h2>社員用のメニュー画面</h2>
<form:form action="${pageContext.request.contextPath}/employee/logout"
method="post">
<input type="submit" value="ログアウト" />
</form:form>
</div>
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:sec="http://www.springframework.org/schema/security"
xsi:schemaLocation="
http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
">
<sec:http pattern="/resources/**" security="none"/>
<!-- add:start -->
<!--
★ポイント1
認証処理2用の設定
UserDetailsやPasswordEncoder等必要なものを新規に定義
-->
<bean id="noOpPasswordEncoder" class="org.springframework.security.crypto.password.NoOpPasswordEncoder" />
<bean id="employeeUserDetails" class="org.terasoluna.tourreservation.domain.service.userdetails.EmployeeUserDetailsService" />
<bean id="employeeRedirectErrorHandler" class="org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler">
<property name="defaultFailureUrl" value="/employee/login?error=true" />
<property name="useForward" value="true" />
</bean>
<!--
★ポイント2
認証処理2用の設定
専用のauthentication-managerを新規に定義
-->
<sec:authentication-manager id="employeeAuthenticationManager">
<sec:authentication-provider user-service-ref="employeeUserDetails">
<sec:password-encoder ref="noOpPasswordEncoder" />
</sec:authentication-provider>
</sec:authentication-manager>
<!--
★ポイント3
認証処理2用の設定
適用範囲を設定して、認証処理2用の認証、認可の設定を新規に定義
-->
<sec:http pattern ="/employee/**"
authentication-manager-ref="employeeAuthenticationManager">
<sec:access-denied-handler ref="accessDeniedHandler"/>
<sec:custom-filter ref="userIdMDCPutFilter" after="ANONYMOUS_FILTER"/>
<sec:form-login
login-page="/employee/login"
login-processing-url="/employee/authenticate"
authentication-failure-handler-ref="employeeRedirectErrorHandler"
default-target-url="/employee"
always-use-default-target="true" />
<sec:logout
logout-url="/employee/logout"
logout-success-url="/employee/login"
delete-cookies="JSESSIONID" />
<sec:intercept-url pattern="/employee/login" access="permitAll" />
<sec:intercept-url pattern="/employee/authenticate" access="permitAll" />
<sec:intercept-url pattern="/employee/logout" access="permitAll" />
<sec:intercept-url pattern="/employee/**" access="hasRole('ROLE_EMPLOYEE')" />
<sec:session-management />
</sec:http>
<!-- add:end -->
<!--
<sec:http>
-->
<!--
★ポイント4
認証処理1用の設定
元々の設定に authentication-manager-ref="authenticationManager" を追加
-->
<sec:http authentication-manager-ref="authenticationManager">
<sec:access-denied-handler ref="accessDeniedHandler"/>
<sec:custom-filter ref="userIdMDCPutFilter" after="ANONYMOUS_FILTER"/>
<sec:form-login
login-page="/login"
authentication-failure-handler-ref="redirectErrorHandler"
authentication-success-handler-ref="redirectHandler" />
<sec:logout
logout-success-url="/"
delete-cookies="JSESSIONID" />
<sec:intercept-url pattern="/tours/*/reserve" access="hasRole('USER')" />
<sec:intercept-url pattern="/reservations/**" access="hasRole('USER')" />
<sec:session-management />
</sec:http>
<sec:global-method-security pre-post-annotations="enabled" />
<!--
<sec:authentication-manager>
-->
<!--
★ポイント5
認証処理1用の設定
元々の設定に id="authenticationManager" を追加
-->
<sec:authentication-manager id="authenticationManager">
<sec:authentication-provider user-service-ref="userDetailsService">
<sec:password-encoder ref="passwordEncoder" />
</sec:authentication-provider>
</sec:authentication-manager>
<bean id="userDetailsService" class="org.terasoluna.tourreservation.domain.service.userdetails.ReservationUserDetailsService" />
<bean id="redirectHandler" class="org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler">
<property name="targetUrlParameter" value="redirectTo"/>
</bean>
<bean id="redirectErrorHandler" class="org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler">
<property name="defaultFailureUrl" value="/login?error=true" />
<property name="useForward" value="true" />
</bean>
<!-- CSRF Protection -->
<bean id="accessDeniedHandler"
class="org.springframework.security.web.access.DelegatingAccessDeniedHandler">
<constructor-arg index="0">
<map>
<entry key="org.springframework.security.web.csrf.InvalidCsrfTokenException">
<bean class="org.springframework.security.web.access.AccessDeniedHandlerImpl">
<property name="errorPage" value="/error/invalidCsrfTokenError" />
</bean>
</entry>
<entry key="org.springframework.security.web.csrf.MissingCsrfTokenException">
<bean class="org.springframework.security.web.access.AccessDeniedHandlerImpl">
<property name="errorPage" value="/error/missingCsrfTokenError" />
</bean>
</entry>
</map>
</constructor-arg>
<constructor-arg index="1">
<bean class="org.springframework.security.web.access.AccessDeniedHandlerImpl">
<property name="errorPage" value="/error/accessDeniedError" />
</bean>
</constructor-arg>
</bean>
<!-- Put UserID into MDC -->
<bean id="userIdMDCPutFilter" class="org.terasoluna.gfw.security.web.logging.UserIdMDCPutFilter" />
</beans>
ポイント | 説明 |
---|---|
★ポイント1 | ・認証処理2で必要となるUserDetails やPasswordEncoder 等を定義する。・デモ用の UserDetails に合わせPasswordEncoder はハッシュ化しない平文のNoOpPasswordEncoder とする。 |
★ポイント2 | ・★ポイント1で定義したBeanを利用し、認証処理2用のauthentication-manager を定義する。・元々ある認証処理2用の authentication-manager と識別するためBeanId も定義する。 |
★ポイント3 | ・★ポイント2で定義したauthentication-manager を利用し、認証処理2用の定義を行う。・ pattern 属性を利用し、適用範囲を設定する。 |
★ポイント4 | ・認証処理1用の設定に、利用するauthentication-manager を識別するため、authentication-manager-ref 属性に★ポイント5で定義するBeanId を設定する。 |
★ポイント5 | ・認証処理1用のauthentication-manager に複数定義するため、識別用にBeanId を設定する。 |
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:sec="http://www.springframework.org/schema/security"
xsi:schemaLocation="
http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
">
<sec:http pattern="/resources/**" security="none"/>
<sec:http>
<sec:access-denied-handler ref="accessDeniedHandler"/>
<sec:custom-filter ref="userIdMDCPutFilter" after="ANONYMOUS_FILTER"/>
<sec:form-login
login-page="/login"
authentication-failure-handler-ref="redirectErrorHandler"
authentication-success-handler-ref="redirectHandler" />
<sec:logout
logout-success-url="/"
delete-cookies="JSESSIONID" />
<sec:intercept-url pattern="/tours/*/reserve" access="hasRole('USER')" />
<sec:intercept-url pattern="/reservations/**" access="hasRole('USER')" />
<sec:session-management />
</sec:http>
<sec:global-method-security pre-post-annotations="enabled" />
<sec:authentication-manager>
<sec:authentication-provider user-service-ref="userDetailsService">
<sec:password-encoder ref="passwordEncoder" />
</sec:authentication-provider>
</sec:authentication-manager>
<bean id="userDetailsService" class="org.terasoluna.tourreservation.domain.service.userdetails.ReservationUserDetailsService" />
<bean id="redirectHandler" class="org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler">
<property name="targetUrlParameter" value="redirectTo"/>
</bean>
<bean id="redirectErrorHandler" class="org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler">
<property name="defaultFailureUrl" value="/login?error=true" />
<property name="useForward" value="true" />
</bean>
<!-- CSRF Protection -->
<bean id="accessDeniedHandler"
class="org.springframework.security.web.access.DelegatingAccessDeniedHandler">
<constructor-arg index="0">
<map>
<entry key="org.springframework.security.web.csrf.InvalidCsrfTokenException">
<bean class="org.springframework.security.web.access.AccessDeniedHandlerImpl">
<property name="errorPage" value="/error/invalidCsrfTokenError" />
</bean>
</entry>
<entry key="org.springframework.security.web.csrf.MissingCsrfTokenException">
<bean class="org.springframework.security.web.access.AccessDeniedHandlerImpl">
<property name="errorPage" value="/error/missingCsrfTokenError" />
</bean>
</entry>
</map>
</constructor-arg>
<constructor-arg index="1">
<bean class="org.springframework.security.web.access.AccessDeniedHandlerImpl">
<property name="errorPage" value="/error/accessDeniedError" />
</bean>
</constructor-arg>
</bean>
<!-- Put UserID into MDC -->
<bean id="userIdMDCPutFilter" class="org.terasoluna.gfw.security.web.logging.UserIdMDCPutFilter" />
</beans>
4. さいごに
今回はSpring Securityでログイン画面を複数定義する方法について説明しました。
<sec:http pattern="" authentication-manager-ref="">
でSpringSecurityの適用範囲を設定することで、ログイン画面だけではなくログイン処理自体を複数定義できることが分かったかと思います。
なお、今回検証してみましたが、個人的にはそもそも一般利用者と管理者でWebアプリ自体を分ける(機能分割)ことを推奨します。