WebAPI(RestController)ベースのSpringBootアプリにHTTPセッションログイン

# ポイントまとめ
- ログイン/ログアウトのエンドポイントはSpringSecurityの設定で自動的に作成される(自分でRestController、RequestMappingする必要はない）
- 必要になるまでCSRFのFilterは無効化しておくと吉
- Webページからのユーザ/パスワード送信は”Formデータ”としてPOST送信する

# コード

## バージョン

- `SpringBoot`: `1.5.7.RELEASE`
- `SpringSecurity`: `4.2.3.RELEASE`

Webページ側もねんのため。

- `Angular`: `４．４．６`

## SpringSecurityの依存追加

これだけでOKです。

```groovy
  compile('org.springframework.boot:spring-boot-starter-security')
```

参考までに、実装時点での`build.gradle`は[こちら](https://github.com/enlighten-series/DomainDictionary/blob/7b6f8e264994470a3682bccbe26586219f1730d9/build.gradle)。

## SpringSecurityの設定

```java

import org.enlightenseries.DomainDictionary.presentation.config.security.AuthenticationFailureHandler;
import org.enlightenseries.DomainDictionary.presentation.config.security.AuthenticationLogoutSuccessHandler;
import org.enlightenseries.DomainDictionary.presentation.config.security.AuthenticationSuccessHandler;
import org.springframework.beans.factory.BeanInitializationException;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;

import javax.annotation.PostConstruct;

@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

  private final AuthenticationManagerBuilder authenticationManagerBuilder;

  private final UserDetailsService userDetailsService;

  public WebSecurityConfiguration(
    // Point.1
    AuthenticationManagerBuilder _authenticationManagerBuilder,
    // Point.2
    UserDetailsService _userDetailsService
  ) {
    this.authenticationManagerBuilder = _authenticationManagerBuilder;
    this.userDetailsService = _userDetailsService;
  }

  @PostConstruct
  public void init() {
    try {
      // Point.3
      authenticationManagerBuilder
        .userDetailsService(userDetailsService)
        .passwordEncoder(passwordEncoder());
    } catch (Exception e) {
      throw new BeanInitializationException("Security configuration failed", e);
    }
  }

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

  @Override
  public void configure(WebSecurity web) throws Exception {
    web
      .ignoring()
        .antMatchers("/**.{js,html}")
        .antMatchers("/h2-console/**");
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    // Point.4
    http.csrf()
      .disable();
      
    // Point.5
    http.formLogin()
      .loginProcessingUrl("/api/login")
      .usernameParameter("username")
      .passwordParameter("password")
      .successHandler(authenticationSuccessHandler())
      .failureHandler(authenticationFailureHandler())
      .permitAll();

    // Point.6
    http.logout()
      .logoutUrl("/api/logout")
      .logoutSuccessHandler(authenticationLogoutSuccessHandler())
      .permitAll();

    http.authorizeRequests()
      .antMatchers(HttpMethod.GET).permitAll()
      .antMatchers("/api/**").authenticated();
  }

  @Bean
  public AuthenticationSuccessHandler authenticationSuccessHandler() {
    return new AuthenticationSuccessHandler();
  }

  @Bean
  public AuthenticationFailureHandler authenticationFailureHandler() {
    return new AuthenticationFailureHandler();
  }

  @Bean
  public AuthenticationLogoutSuccessHandler authenticationLogoutSuccessHandler() {
    return new AuthenticationLogoutSuccessHandler();
  }
}

```

### Point.1

`AuthenticationManagerBuilder`のBeanは、特にどこに記述しなくても、SpringSecurityが勝手に用意してくれてます。Injectして必要な設定（[Point.3](#point3)参照)を施すだけでOKです。

### Point.2

`UserDetailsService`はSpringの[`UserDetailsService`](https://docs.spring.io/spring-security/site/docs/4.2.3.RELEASE/apidocs/org/springframework/security/core/userdetails/UserDetailsService.html)インタフェースを実装したカスタムクラスです。

ログインのエンドポイントに送られたユーザ名から、DBなど任意の方法で保存した既存ユーザを取得する処理を記述します。
ここではDBアクセスせずにダミーのユーザを返します。

```java
@Component
public class MyUserDetailsService implements UserDetailsService {

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    if (!username.equals("admin")) {
      return null;
    }

    List<GrantedAuthority> grantedAuthorities = AuthorityUtils.createAuthorityList("ADMIN");

    // ユーザ名"admin"、パスワード"password" でログインできるダミーのユーザを返します。
    return new org.springframework.security.core.userdetails.User(
      "admin",
      //パスワードの文字をエンコードした文字を用意しておきます（本来エンコード済み文字がDB等に保存される）
      new BCryptPasswordEncoder().encode("password"),
      grantedAuthorities
    );
  }

}
```

### Point.3

`AuthenticationManagerBuilder`に、ユーザ取得処理としてカスタムした`UserDetailsService`と、パスワードの暗号化に使用するエンコーダ（ここでは[`BCryptPasswordEncoder`](https://docs.spring.io/spring-security/site/docs/4.2.3.RELEASE/apidocs/org/springframework/security/crypto/bcrypt/BCryptPasswordEncoder.html)を使用しています)を設定します。

これで「Webページから送信されたユーザをDBから探し、パスワードを照合する」という処理をSpringSecurtyに任せることができます。

### Point.4

次の処理でCSRFのFilterを無効化しておきます。

```java
http.csrf().disable();
```
CSRFのFilterはデフォルトでは有効になっているため、ログイン/ログアウトのためのPOST送信にCSRFトークンのチェックが入ります。
ブラウザに余計なCookieが残っていると、次のような`４０３`エラーが発生してログイン/ログアウト処理がうまく検証できません。

```
Invalid CSRF Token 'null' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'.
```

### Point.5

ログインのエントリポイント、検証時の振る舞いを定義します。

- ここでは、ログイン時は`(localhost:8080)/api/login`に向けてPOST送信するように構成します。

```java
    http.formLogin()
      .loginProcessingUrl("/api/login")
```

- `usernameParameter`、`passwordParameter`で、送信するフォームパラメータの名前を指定します。

```java
      .usernameParameter("username")
      .passwordParameter("password")
```

- デフォルトではログイン処理のレスポンスに、ステータス`302`が返されてしまいます。ここでは成功時は`200`だけ、失敗時は`401`だけを返すようにカスタマイズを行います。

```java
      .successHandler(authenticationSuccessHandler())
      .failureHandler(authenticationFailureHandler())
```

```java
  @Bean
  public AuthenticationSuccessHandler authenticationSuccessHandler() {
    return new AuthenticationSuccessHandler();
  }

  @Bean
  public AuthenticationFailureHandler authenticationFailureHandler() {
    return new AuthenticationFailureHandler();
  }
```

> ```java
> import org.springframework.security.core.Authentication;
> import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
> 
> import javax.servlet.ServletException;
> import javax.servlet.http.HttpServletRequest;
> import javax.servlet.http.HttpServletResponse;
> import java.io.IOException;
> 
> public class AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
>   @Override
>   public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
>     response.setStatus(HttpServletResponse.SC_OK);
>   }
> }
> ```
> 
> ```java
> import org.springframework.security.core.AuthenticationException;
> import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
> 
> import javax.servlet.ServletException;
> import javax.servlet.http.HttpServletRequest;
> import javax.servlet.http.HttpServletResponse;
> import java.io.IOException;
> 
> public class AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
>   @Override
>   public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
>     response.setStatus(HttpServletResponse.SC_UNAUTHORIZED, "Authentication failed.");
>   }
> }
> ```

### Point.6

ログアウトのエントリポイントは`(localhost:8080)/api/logout`を指定します。
ここにむけてPOST送信を行えばOKです。
また、ログアウト処理のレスポンスも`302`にならないように構成します。

```java
      .logoutSuccessHandler(authenticationLogoutSuccessHandler())
```

```java
  @Bean
  public AuthenticationLogoutSuccessHandler authenticationLogoutSuccessHandler() {
    return new AuthenticationLogoutSuccessHandler();
  }
```

> ```java
> import org.springframework.security.core.Authentication;
> import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
> 
> import javax.servlet.ServletException;
> import javax.servlet.http.HttpServletRequest;
> import javax.servlet.http.HttpServletResponse;
> import java.io.IOException;
> 
> public class AuthenticationLogoutSuccessHandler implements LogoutSuccessHandler {
>   @Override
>   public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
>     response.setStatus(HttpServletResponse.SC_OK);
>   }
> }
> ```

SpringBootアプリ側は、これでOKです。自前のRequestMapping等を作成する必要はありません。

## Webページからのリクエスト

注意するのは１つだけ、FormデータとしてPOST送信する、ということだけです。

今回はAngularで作成していましたので、そのコードを記載します。

```typescript
  username: string;
  password: string;

  login() {
    const data = 'username=' + encodeURIComponent(this.username) +
      '&password=' + encodeURIComponent(this.password);
    const headers = new HttpHeaders ({
      'Content-Type': 'application/x-www-form-urlencoded'
    });

    this.http.post('/api/login', data, { headers: headers })
    .subscribe(...);
  }
```

```html
      <input type="text" name="username" [(ngModel)]="username" required>
      <input type="password" name="password" [(ngModel)]="password" required>
```

# 動作確認

ログインに成功すると、ステータス`200`とともに、リクエストヘッダに`Set-Cookie:JSESSIONID=ながーいランダム文字列`が返ってきます。

これでセッション認証OKです。

ログアウト時に`Set-Cookie:JSESSIONID=`(Cookieのクリア)は返ってきません。サーバ側のメモリ上だけでクリアされるようです。

# 参考

- https://docs.spring.io/spring-security/site/docs/4.2.3.RELEASE/reference/html/csrf.html
- [JHipster](http://www.jhipster.tech/) (yeomanのジェネレータ)の生成コード