Spring Securityでログイン画面を複数定義する方法

## 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. 実現方法、ポイント
1. 認証情報（UserDetails）および認証処理（UserDetailsService）をそれぞれ用意する。
2. ログイン画面や認証後に表示する画面（たとえばメニュー画面等）をそれぞれ用意する。
3. Bean定義において、それぞれに対応する`<sec:authentication-manager>`を用意する。
4. Bean定義において、`<sec:http>`要素の`pattern`属性に適用範囲を設定する。
5. Bean定義において、`<sec:http>`要素の`authentication-manager-ref`属性に、3で定義した`authentication-manager`のBeanIdを設定する。

## 3. 検証用のサンプルコード
参考までに、検証用に作成したサンプルコードを記載します。

### 3.1. 概要
検証アプリをゼロから作成するのは手間が掛かるため、公開されているTERASOLUNA5.xのデモアプリである「[Tour Reservation Sample Application](https://github.com/terasolunaorg/terasoluna-tourreservation-mybatis3)」に、もう一つのログイン画面（処理）を追加したいと思います。

[ここ](https://github.com/terasolunaorg/terasoluna-tourreservation-mybatis3/releases)からデモアプリ（`terasoluna-tourreservation-mybatis3-5.3.0.RELEASE.zip`）をダウンロードして任意のディレクトリに展開してください。

デモアプリには顧客用のログイン画面（処理）が実装されています。ここに社員用のログイン画面（処理）を追加していきます。追加するファイルとその概要については以下の表を参照ください。

**なお、今回修正するSpringSecurityの設定を行っているBean定義ファイルは`terasoluna-tourreservation-web/src/main/resources/META-INF/spring/spring-security.xml`になります。**

| 項番 |認証処理１<br />（元々実装されているもの）|認証処理２<br />（今回追加するもの）| 説明 |
|:---:|:----------|:----------|:-----|
| 1 | `ReservationUserDetails.java` | `EmployeeUserDetails.java` |・認証情報（UserDetails）<br />・ユーザ情報のクラスがCustomerとEmployeeで異なる。 |
| 2 | `ReservationUserDetailsService.java` | `EmployeeUserDetailsService.java` |・認証処理（UserDetailsService）<br />・今回追加する方はDBアクセスを行わず、ダミーのEmployeeを返す。<br />・パスワードも平文とする。 |
| 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` |・メニュー画面<br />・今回追加する方はログアウトボタンだけがあるメニューとする。 |
| 5 | | `EmployeeLoginController.java` |・ログイン画面とメニュー画面を表示するためのController<br />・認証時のPOSTに対応するため、GETとPOSTの両方をハンドリングする。 |

### 3.2. サンプルコード

```java:EmployeeUserDetails.java
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 + "]";
    }   
}
```

```java:EmployeeUserDetailsService.java
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`とします。

```java:EmployeeLoginController.java
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`の両方でマッピングします。

```jsp:WEB-INF/views/login/employeeForm.jsp
<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>
```

```jsp:WEB-INF/views/menu/employeeMenu.jsp
<div class="container">
	<h2>社員用のメニュー画面</h2>

	<form:form action="${pageContext.request.contextPath}/employee/logout"
		method="post">
		<input type="submit" value="ログアウト" />
	</form:form>

</div>
```

```xml:spring-security.xml（修正後）
<?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
        認証処理２用の設定
        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
        認証処理２用の設定
        専用の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
        認証処理２用の設定
        適用範囲を設定して、認証処理２用の認証、認可の設定を新規に定義
    -->
    <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
        認証処理１用の設定
        元々の設定に 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
        認証処理１用の設定
        元々の設定に 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 |・認証処理２で必要となる`UserDetails`や`PasswordEncoder`等を定義する。<br />・デモ用の`UserDetails`に合わせ`PasswordEncoder`はハッシュ化しない平文の`NoOpPasswordEncoder`とする。  |
| ★ポイント2 |・★ポイント1で定義したBeanを利用し、認証処理２用の`authentication-manager`を定義する。<br />・元々ある認証処理２用の`authentication-manager`と識別するため`BeanId`も定義する。  |
| ★ポイント3 |・★ポイント2で定義した`authentication-manager`を利用し、認証処理２用の定義を行う。<br />・`pattern`属性を利用し、適用範囲を設定する。  |
| ★ポイント4 |・認証処理１用の設定に、利用する`authentication-manager`を識別するため、`authentication-manager-ref`属性に★ポイント5で定義する`BeanId`を設定する。  |
| ★ポイント5 |・認証処理１用の`authentication-manager`に複数定義するため、識別用に`BeanId`を設定する。  |

```xml:spring-security.xml（参考として修正前）
<?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アプリ自体を分ける（機能分割）ことを推奨します。**