Java
spring-security
spring-boot

Spring Security with Spring Boot 2.0で簡単なRest APIを実装する

概要

Spring SecurityとSpring Bootを利用して簡単なRest APIのデモアプリケーションを実装しました。
記事の前半はSpring Security周りの実装、後半がコントローラの実装とそのテストコードについて説明しています。

ソースコードはrubytomato/demo-security-spring2にあります。

環境

  • Windows 10 Professional
  • Java 1.8.0_172
  • Spring Boot 2.0.2
  • Spring Security 5.0.5

参考

デモアプリケーションの要件

認証の仕方

このデモアプリケーションは、メールアドレス・パスワードで認証を行います。具体的にはログインAPIにメールアドレスとパスワード、CSRFトークンをPOSTリクエストし、認証できればHTTPステータス200とセッションクッキーおよび新しいCSRFトークンクッキーが返ります。
以降の認証、認可はセッションクッキーによって行います。

HTTPステータスコードについて

サーバーサイドでページをレンダリングするようなアプリケーションの場合、特定の挙動で決められたページへリダイレクト(またはフォワード)させる場合がありますが(たとえばログイン後に元のページへ戻る、リクエストの失敗時にエラーページへ遷移するなど)、このデモアプリケーションでは基本的にそのような挙動はせず、リダイレクトが必要な場合はクライアント側に任せるという想定です。

挙動 HTTPステータスコード 備考
ログイン成功時 200 (Ok) セッションの生成
CSRFトークンの生成
ログイン失敗時 401 (Unauthorized) パスワード間違い
メールアドレス間違い
ログアウト成功時 200 (Ok) セッション破棄
クッキー削除
ログアウト失敗時 500 (Internal Server Error)
APIの成功時 200 (Ok) 認証、認可済みでAPIを実行
APIの正常終了の結果をレスポンス
APIの失敗時 400 (Bad Request)
404 (Not Found)
500 (Internal Server Error)
認証、認可済みでAPIを実行
APIの異常終了をレスポンス
APIの認証エラー 401 (Unauthorized) 未認証でAPIを呼び出した場合
セッション切れ、CSRFトークンの不正なども含む
APIは未実行
APIの認可エラー 403 (Forbidden) 認証済みだが認可されていないAPIを呼び出した場合
APIは未実行

ユーザー情報の保持

ユーザー情報はUSERテーブルにユーザー名、ハッシュ化されたパスワード、メールアドレス(一意)、管理者フラグといった項目で保持します。
管理者フラグ(admin_flag)はユーザーのアプリケーションに対するロールを持ち、1がADMINロール、0がUSERロールです。
一部のAPIではロールによる認可を行うという想定です。

CREATE TABLE IF NOT EXISTS `user` (
  id BIGINT AUTO_INCREMENT,
  `name` VARCHAR(128) NOT NULL,
  password VARCHAR(256) NOT NULL,
  email VARCHAR(256) NOT NULL,
  admin_flag BOOLEAN NOT NULL DEFAULT FALSE,
  PRIMARY KEY (id),
  UNIQUE KEY (email)
)
ENGINE = INNODB,
CHARACTER SET = utf8mb4,
COLLATE utf8mb4_general_ci;

デモアプリケーションで使用するユーザーデータは次のようなものです。
パスワードは後述するBCryptPasswordEncoderクラスのencodeメソッドでハッシュ化したものです。

> select * from user;
+----+----------+--------------------------------------------------------------+-----------------------+------------+
| id | name     | password                                                     | email                 | admin_flag |
+----+----------+--------------------------------------------------------------+-----------------------+------------+
|  1 | kamimura | $2a$10$yiIGwxNPWwJ3CZ0SGAq3i.atLYrQNhzTyep1ALi6dbax1b1R2Y.cG | kkamimura@example.com |          1 |
|  2 | sakuma   | $2a$10$9jo/FSVljst5xJjuw9eyoumx2iVCUA.uBkUKeBo748bUIaPjypbte | rsakuma@example.com   |          0 |
|  3 | yukinaga | $2a$10$1OXUbgiuuIi3SOO3t.jyZOEY66ELL03dRcGpAKWql8HBXOag4YZ8q | tyukinaga@example.com |          0 |
+----+----------+--------------------------------------------------------------+-----------------------+------------+
3 rows in set (0.00 sec)

このデモアプリケーションには実装していませんが、ユーザーを新規登録するときに入力されたパスワードをBCryptPasswordEncoder.encodeでハッシュ化して永続化しているという想定です。

PasswordEncoder encoder = new BCryptPasswordEncoder();

User user = User.of(usernme, encoder.encode(rawPassword), email);
userRepository.save(user);

セキュリティの実装

Spring Securityのコンフィグレーション

WebSecurityConfigurerAdapterを継承したクラスにセキュリティの実装を行います。
個々の実装は別途説明します。

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            // AUTHORIZE
            .authorizeRequests()
                .mvcMatchers("/prelogin", "/hello/**")
                    .permitAll()
                .mvcMatchers("/user/**")
                    .hasRole("USER")
                .mvcMatchers("/admin/**")
                    .hasRole("ADMIN")
                .anyRequest()
                    .authenticated()
            .and()
            // EXCEPTION
            .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint())
                .accessDeniedHandler(accessDeniedHandler())
            .and()
            // LOGIN
            .formLogin()
                .loginProcessingUrl("/login").permitAll()
                    .usernameParameter("email")
                    .passwordParameter("pass")
                .successHandler(authenticationSuccessHandler())
                .failureHandler(authenticationFailureHandler())
            .and()
            // LOGOUT
            .logout()
                .logoutUrl("/logout")
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
                .logoutSuccessHandler(logoutSuccessHandler())
                //.addLogoutHandler(new CookieClearingLogoutHandler())
            .and()
             // CSRF
            .csrf()
                //.disable()
                //.ignoringAntMatchers("/login")
                .csrfTokenRepository(new CookieCsrfTokenRepository())
            ;
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth,
                                @Qualifier("simpleUserDetailsService") UserDetailsService userDetailsService,
                                PasswordEncoder passwordEncoder) throws Exception {
        auth.eraseCredentials(true)
                .userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder);
    }

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

    AuthenticationEntryPoint authenticationEntryPoint() {
        return new SimpleAuthenticationEntryPoint();
    }

    AccessDeniedHandler accessDeniedHandler() {
        return new SimpleAccessDeniedHandler();
    }

    AuthenticationSuccessHandler authenticationSuccessHandler() {
        return new SimpleAuthenticationSuccessHandler();
    }

    AuthenticationFailureHandler authenticationFailureHandler() {
        return new SimpleAuthenticationFailureHandler();
    }

    LogoutSuccessHandler logoutSuccessHandler() {
        return new HttpStatusReturningLogoutSuccessHandler();
    }

}

認可の設定

configuration
.authorizeRequests()
    .mvcMatchers("/prelogin", "/hello/**")
        .permitAll()
    .mvcMatchers("/user/**")
        .hasRole("USER")
    .mvcMatchers("/admin/**")
        .hasRole("ADMIN")
    .anyRequest()
        .authenticated()

APIの認可の設定を行います。

  • permitAll()は、認証、ロールに関係なく許可します。
  • hasRole()は、認証されたユーザーが指定されたロールを持つ場合に許可します。
  • authenticated()は、認証されたユーザーの場合に許可します。

認証、認可の例外処理

configuration
.exceptionHandling()
    .authenticationEntryPoint(authenticationEntryPoint())
    .accessDeniedHandler(accessDeniedHandler())

authenticationEntryPoint()

15.2.1 AuthenticationEntryPoint

未認証のユーザーが認証の必要なAPIにアクセスしたときの処理を設定します。

AuthenticationEntryPoint authenticationEntryPoint() {
    return new SimpleAuthenticationEntryPoint();
}

デフォルトや用意されている標準実装クラスは利用せず、HTTPステータス401とデフォルトのメッセージを返すだけの処理を実装します。

SimpleAuthenticationEntryPoint
public class SimpleAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException exception) throws IOException, ServletException {
        if (response.isCommitted()) {
            log.info("Response has already been committed.");
            return;
        }
        response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
    }

}

デフォルトのメッセージとは次のようなものです。messageはsendErrorメソッドの2番目のパラメータに指定した値(HttpStatus.UNAUTHORIZED.getReasonPhrase)です。

HTTP/1.1 401
// 省略

{
  "timestamp" : "2018-04-08T21:13:24.918+0000",
  "status" : 401,
  "error" : "Unauthorized",
  "message" : "Unauthorized",
  "path" : "/app/memo/1"
}

standard implementations

  • LoginUrlAuthenticationEntryPoint

AuthenticationException

AuthenticationExceptionのサブクラスでより詳細な例外理由を知ることができる場合があります。

  • BadCredentialsException
    • 認証情報が無効な場合にスローされる
  • LockedException
    • アカウントがロックされている場合にスローされる
  • DisabledException
    • アカウントが無効な場合にスローされる
  • AccountExpiredException
    • アカウントの有効期限が切れている場合にスローされる
  • CredentialsExpiredException
    • 認証情報の有効期限が切れいている場合にスローされる
  • SessionAuthenticationException
    • 同一ユーザーによる最大セッション数が設定を超えた場合にスローされる

accessDeniedHandler()

15.2.2 AccessDeniedHandler

ユーザーは認証済みだが未認可のリソースへアクセスしたときの処理を設定します。

AccessDeniedHandler accessDeniedHandler() {
    return new SimpleAccessDeniedHandler();
}

デフォルトや用意されている標準実装クラスは利用せず、HTTPステータス403とデフォルトのメッセージを返すだけのハンドラを実装します。

SimpleAccessDeniedHandler
public class SimpleAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException exception) throws IOException, ServletException {
        response.sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());
    }

}

standard implementations

  • (default) AccessDeniedHandlerImpl

AccessDeniedException

AccessDeniedExceptionのサブクラスでより詳細な例外理由を知ることができます。

  • AuthorizationServiceException
    • AccessDecisionManagerの実装に問題があった場合にスローされることがあります
  • org.springframework.security.web.server.csrf.CsrfException
    • CSRFトークンが無効な場合にスローされます
  • org.springframework.security.web.csrf.CsrfException
    • CSRFトークンが無効な場合にスローされます
      • InvalidCsrfTokenException
        • リクエストのCSRFトークンと一致しない場合にスローされます
      • MissingCsrfTokenException
        • リクエストにCSRFトークンが見つからない場合にスローされます

認証と成功・失敗時の処理

15.4.1 Application Flow on Authentication Success and Failure

configuration
.formLogin()
    .loginProcessingUrl("/login").permitAll()
        .usernameParameter("email")
        .passwordParameter("pass")
    .successHandler(authenticationSuccessHandler())
    .failureHandler(authenticationFailureHandler())

loginProcessingUrl()

ログインページとパラメータ名の設定を行います。このページへのアクセスに認証は不要とします。(permitAll)

successHandler()

認証が成功した時の処理を実装したハンドラを設定します。

AuthenticationSuccessHandler authenticationSuccessHandler() {
    return new SimpleAuthenticationSuccessHandler();
}

デフォルトや用意されている標準実装クラスは利用せず、HTTPステータス200を返すだけのハンドラを実装します。

SimpleAuthenticationSuccessHandler
@Slf4j
public class SimpleAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication auth) throws IOException, ServletException {
        if (response.isCommitted()) {
            log.info("Response has already been committed.");
            return;
        }

        response.setStatus(HttpStatus.OK.value());
        clearAuthenticationAttributes(request);
    }

    /**
     * Removes temporary authentication-related data which may have been stored in the
     * session during the authentication process.
     */
    private void clearAuthenticationAttributes(HttpServletRequest request) {
        HttpSession session = request.getSession(false);

        if (session == null) {
            return;
        }
        session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
    }
}

standard implementations

  • SimpleUrlAuthenticationSuccessHandler
  • SavedRequestAwareAuthenticationSuccessHandler

failureHandler()

認証が失敗した時の処理を実装したハンドラを設定します。

AuthenticationFailureHandler authenticationFailureHandler() {
    return new SimpleAuthenticationFailureHandler();
}

デフォルトや用意されている標準実装クラスは利用せず、HTTPステータス403とデフォルトのメッセージを返すだけのハンドラを実装します。

SimpleAuthenticationFailureHandler
public class SimpleAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        response.sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());
    }

}

standard implementations

  • SimpleUrlAuthenticationFailureHandler
  • ExceptionMappingAuthenticationFailureHandler
  • DelegatingAuthenticationFailureHandler

ログアウト時の処理

configuration
.logout()
    .logoutUrl("/logout")
    .invalidateHttpSession(true)
    .deleteCookies("JSESSIONID")
    .logoutSuccessHandler(logoutSuccessHandler())
    //.addLogoutHandler(new CookieClearingLogoutHandler())

logoutUrl()

ログアウトページの設定を行います。

logoutSuccessHandler()

ログアウトが正常終了した時の処理を実装したハンドラを設定します。
HTTPステータスを返すだけのSpring Securityの標準実装クラスHttpStatusReturningLogoutSuccessHandlerがあるのでこれを利用しました。
ログアウト時に行うセッション破棄やクッキー削除はコンフィグレーションで行うので実装は不要です。

5.5.2 LogoutSuccessHandler

LogoutSuccessHandler logoutSuccessHandler() {
  return new HttpStatusReturningLogoutSuccessHandler();
}

standard implementations

  • SimpleUrlLogoutSuccessHandler
  • HttpStatusReturningLogoutSuccessHandler

addLogoutHandler()

このデモアプリケーションでは利用しませんでしたが、ログアウトが終了したときに実行するハンドラを追加することができます。

standard implementations

  • CookieClearingLogoutHandler
  • CsrfLogoutHandler
  • SecurityContextLogoutHandler

CSRF

デフォルトではCSRF対策は有効になっていてHttpSessionにCSRFトークンを保持します。
ログインAPIもCSRF対策の対象なのでログイン時にCSRFトークンが必要になりますが、ログインAPIでは不要にしたい場合はignoringAntMatchersにURLを指定します。

configuration
.csrf()
    //.ignoringAntMatchers("/login")
    .csrfTokenRepository(new CookieCsrfTokenRepository())

CSRF対策を無効にしたい場合はdisableを追加します。

configuration
.csrf()
    .disable()

csrfTokenRepository

CSRFトークンをcookieに保持する標準実装クラスCookieCsrfTokenRepositoryを利用しました。

standard implementations

  • (default) HttpSessionCsrfTokenRepository
    • HttpSessionにCSRFトークンを保持します。
  • CookieCsrfTokenRepository
    • "XSRF-TOKEN"という名前のクッキーにCSRFトークンを保持します。
  • LazyCsrfTokenRepository
    • CSRFトークンの生成を必要になるまで遅らせる実装のようですが具体的な使用方法は調べ切れていません。

HEADER

デフォルトではキャッシュコントロールが無効で且つ以下のヘッダーが設定されます。
このデモアプリケーションでは特にカスタマイズする必要がないのでデフォルトのままにしています。

X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY

キャッシュコントロールを無効にしたい場合

configuration
.headers()
    .cacheControl()
        .disable()

他のオプションも無効にしたい場合

configuration
.headers()
    .cacheControl()
        .disable()
    .frameOptions()
        .disable()
    .xssProtection()
        .disable()
    .contentTypeOptions()
        .disable()

任意のヘッダーを追加したい場合

.headers()
    .addHeaderWriter(new StaticHeadersWriter("X-TEST-STATIC-HEADER", "dummy_value"))

認証処理のコンフィグレーション

@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth,
    @Qualifier("simpleUserDetailsService") UserDetailsService userDetailsService,
    PasswordEncoder passwordEncoder) throws Exception {

    auth.eraseCredentials(true)
        .userDetailsService(userDetailsService)
        .passwordEncoder(passwordEncoder);

}

UserDetailsService

9.2.2 The UserDetailsService
10.2 UserDetailsService Implementations

UserDetailsServiceインターフェースはloadUserByUsernameというメソッドを1つだけ定義しています。
このインタフェースを実装するクラスはloadUserByUsernameをオーバーライドして、UserDetailsインターフェースを実装した任意の認証情報クラスを返す必要があります。

このデモアプリケーションではユーザー情報はデータベースのUSERテーブルに格納しているので、下記の例の通りUserRepositoryを使ってUSERテーブルを検索し、ユーザーが見つかればUserDetailsインタフェースを実装した認証情報クラスSimpleLoginUserのインスタンスを生成して返します。

@Service("simpleUserDetailsService")
public class SimpleUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public SimpleUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(final String email) {
        // emailでデータベースからユーザーエンティティを検索する
        return userRepository.findByEmail(email)
                .map(SimpleLoginUser::new)
                .orElseThrow(() -> new UsernameNotFoundException("user not found"));
    }
}
UserDetails

UserDetailsとは何かは、リファレンスページの説明がわかりやすいですので下記に一部抜粋しました。

9.2.2 The UserDetailsService

UserDetails is a core interface in Spring Security. It represents a principal, but in an extensible and application-specific way. Think of UserDetails as the adapter between your own user database and what Spring Security needs inside the SecurityContextHolder.

UserDetailsを実装したUserを継承してアプリケーション固有の認証情報クラスSimpleLoginUserを実装します。
アプリケーションの要件で必要な情報があればこのクラスのフィールドに定義します。この例ではUserエンティティのインスタンスを定義しています。

SimpleLoginUser
public class SimpleLoginUser extends org.springframework.security.core.userdetails.User {

    // Userエンティティ
    private com.example.demo.entity.User user;

    public User getUser() {
        return user;
    }

    public SimpleLoginUser(User user) {
        super(user.getName(), user.getPassword(), determineRoles(user.getAdmin()));
        this.user = user;
    }

    private static final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");
    private static final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER");

    private static List<GrantedAuthority> determineRoles(boolean isAdmin) {
        return isAdmin ? ADMIN_ROLES : USER_ROLES;
    }
}

Password Encoder

10.3 Password Encoding

パスワードのエンコードは標準実装クラスのBCryptPasswordEncoderを利用しました。他にも標準実装クラスがいくつかありますが、アプリケーションの要件に合わなければPasswordEncoderインターフェースを実装して必要なエンコーダを作ることもできます。

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

standard implementations

  • BCryptPasswordEncoder
    • BCrypt strong hashing function
  • Pbkdf2PasswordEncoder
    • PBKDF2 with a configurable number of iterations and a random 8-byte random salt value.
  • SCryptPasswordEncoder
    • SCrypt hashing function
  • StandardPasswordEncoder (deprecated)
    • SHA-256 hashing with 1024 iterations and a random 8-byte random salt value.
  • NoOpPasswordEncoder (deprecated)
    • パスワードをエンコードせずplain textで扱うテスト向けのエンコーダです

DelegatingPasswordEncoder

Spring Security 5.0より実装されたエンコーダです。
従来は一度エンコードのアルゴリズムを決めると採用したアルゴリズムを後から変えにくいという問題がありました。

このエンコーダはクラス名の通り既存のエンコードクラスへ処理をデリゲートしますが、エンコードしたハッシュ値の先頭にアルゴリズムのIDを付加します。
デフォルトではBCryptPasswordEncoderが使用されるので、以下の例の通りハッシュ値にBcryptを示す{bcrpt}が付加されます。

DelegatingPasswordEncoderは、このIDを参照して使用するエンコードクラスを決定します。

PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
String password = encoder.encode("dummy_password");
System.out.println(password);
// {bcrypt}$2a$10$7qLNvQ7CZ80.VcZGtfe2QuMk7NlWP8ktJyEZoqToo1L7.zi9dIy76
  • BCryptPasswordEncoder : {bcrypt}
  • Pbkdf2PasswordEncoder : {pbkdf2}
  • SCryptPasswordEncoder : {scrypt}
  • NoOpPasswordEncoder : {noop}
  • StandardPasswordEncoder : {sha256}

実装するコントローラ

39.5 Spring MVC and CSRF Integration

コントローラの引数に

  • 認証されているユーザーの認証情報を受け取れます。
    • UserDetailsインターフェースを実装したクラス
    • AuthenticationPrincipalArgumentResolverの機能
  • リクエストに含まれる(パラメータやヘッダー)CSRFトークンを受け取れます。
    • CsrfTokenクラス
    • CsrfTokenArgumentResolverの機能
@GetMapping
public String greeting(@AuthenticationPrincipal(expression = "user") User user, CsrfToken csrfToken) {
    log.debug("token : {}", csrfToken.getToken());
    log.debug("access user : {}", user.toString());

}

プレログインAPI

ログインAPIで必要なCSRFトークンを返すAPIです。

method path body content type request body
GET /prelogin
@RestController
@RequestMapping(path = "prelogin")
public class PreLoginController {

    @GetMapping
    public String preLogin(HttpServletRequest request) {
        DefaultCsrfToken token = (DefaultCsrfToken) request.getAttribute("_csrf");
        if (token == null) {
            throw new RuntimeException("could not get a token.");
        }
        return token.getToken();
    }

}

ログイン/ログアウトAPI

Security Configurationの設定で有効になるため実装は不要です。

method path body content type request body
POST /login application/x-www-form-urlencoded email={email}
pass={password}
_csrf={CSRF-TOKEN}
POST /logout

認証が不要なAPI

認証も認可も不要でだれでもアクセスできるAPIです。

method path body content type request body
GET /hello
GET /hello/{message}
POST /hello application/x-www-form-urlencoded message={message}
HelloController
@RestController
@RequestMapping(path = "hello")
@Slf4j
public class HelloController {

    @GetMapping
    public String greeting() {
        return "hello world";
    }

    @GetMapping(path = "{message}")
    public String greeting(@PathVariable(name = "message") String message) {
        return "hello " + message;
    }

    @PostMapping
    public String postGreeting(@RequestParam(name = "message") String message) {
        return "hello " + message;
    }

}

認証が必要で認可が不要なAPI

認証されたユーザーであればだれでもアクセスできるAPIです。

method path body content type request body
GET /memo/1
GET /memo/list
@RestController
@RequestMapping(path = "memo")
public class MemoController {

    private final MemoService service;

    public MemoController(MemoService service) {
        this.service = service;
    }

    @GetMapping(path = "{id}", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public ResponseEntity<Memo> id(@PathVariable(value = "id") Long id) {
        Optional<Memo> memo = service.findById(id);
        return memo.map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @GetMapping(path = "list", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public ResponseEntity<List<Memo>> list(Pageable page) {
        Page<Memo> memos = service.findAll(page);
        return ResponseEntity.ok(memos.getContent());
    }

}

認証とUSERロールが必要なAPI

認証されたユーザーがUSERロールを持っている場合にアクセスできるAPIです。

method path body content type request body
GET /user
GET /user/echo/{message}
POST /user/echo application/json {"{key}": "{value}"}
@RestController
@RequestMapping(path = "user")
public class UserController {

    @GetMapping
    public String greeting(@AuthenticationPrincipal(expression = "user") User user) {
        return "hello " + user.getName();
    }

    @GetMapping(path = "echo/{message}")
    public String getEcho(@PathVariable(name = "message") String message) {
        return message.toUpperCase();
    }

    @PostMapping(path = "echo", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public String postEcho(@RequestBody Map<String, String> message) {
        return message.toString();
    }

}

認証されたユーザーの認証オブジェクトをハンドラメソッドの引数に取ることができます。

public String greeting(@AuthenticationPrincipal SimpleLoginUser loginUser) {
    User user = loginUser.getUser();

    // 省略
}

expressionに認証オブジェクトのgetterメソッド(たとえばgetUserだったらuser)を指定すれば、そのオブジェクトを直接取得することができます。

public String greeting(@AuthenticationPrincipal(expression = "user") User user) {

    // 省略
}

認証とADMINロールが必要なAPI

認証されたユーザーがADMINロールを持っている場合にアクセスできるAPIです。

method path body content type request body
GET /admin
GET /admin/{username}
GET /admin/echo/{message}
POST /admin/echo application/json {"{key}": "{value}"}
@RestController
@RequestMapping(path = "admin")
public class AdminController {

    private final UserService userService;

    public AdminController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public String greeting(@AuthenticationPrincipal(expression = "user") User user) {
         return "hello admin " + user.getName();
    }

    @GetMapping(path = "{name}")
    public String greeting(@AuthenticationPrincipal(expression = "user") User user, @PathVariable(name = "name") String name) {
         return userService.findByName(name).map(u -> "hello " + u.getName()).orElse("unknown user");
    }

    @GetMapping(path = "echo/{message}")
    public String getEcho(@PathVariable(name = "message") String message) {
        return message.toUpperCase();
    }

    @PostMapping(path = "echo", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public String postEcho(@RequestBody Map<String, String> message) {
        return message.toString();
    }

}

APIの動作確認

APIの動作確認はcurlコマンドで行いました。認証結果のクッキーは-cオプションでテキストファイルに保存、送信時に-bオプションでそのテキストファイルを指定します。

認証の不要なAPI

未認証ユーザーの場合

> curl -i "http://localhost:9000/app/hello/world"

HTTP/1.1 200

認証が不要なAPIでもCSRFの対象になっていればPOST時にCSRFトークンクッキーとx-xsrf-tokenヘッダーにCSRFトークンが必要です。

NG
> curl -i -X POST "http://localhost:9000/app/hello" -d "message=WORLD"

HTTP/1.1 401
OK
> curl -i -b cookie.txt -X POST "http://localhost:9000/app/hello" -d "message=WORLD" -d "_csrf={CSRF-TOKEN}"

HTTP/1.1 200

認証済みユーザーの場合

> curl -i -b cookie.txt "http://localhost:9000/app/hello/world"

HTTP/1.1 200
> curl -i -b cookie.txt -X POST "http://localhost:9000/app/hello" -d "message=WORLD" -d "_csrf={CSRF-TOKEN}"

HTTP/1.1 200

プレログインAPI

ログイン時に必要なCSRF-TOKENを返すAPIです。

prelogin
> curl -i -c cookie.txt "http://localhost:9000/app/prelogin"

HTTP/1.1 200

{CSRF-TOKEN}

ログインAPI

先にプレログインAPIにアクセスしてログイン時に使用するCSRF-TOKENを取得しておきます。

有効なアカウントの場合

login
> curl -i -b cookie.txt -c cookie.txt -X POST "http://localhost:9000/app/login" -d "email=kkamimura@example.com" -d "pass=iWKw06pvj" -d "_csrf={CSRF-TOKEN}"

HTTP/1.1 200

ちなみにcookie.txtには次のような内容が書き込まれています。

> type cookie.txt

# Netscape HTTP Cookie File
# http://curl.haxx.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_localhost     FALSE   /app    FALSE   0       XSRF-TOKEN      d10bdddd-d66d-4cfb-9417-fcdb9a3d4d71
#HttpOnly_localhost     FALSE   /app    FALSE   0       JSESSIONID      99096C52A9CCDC52ED4A15BCB0079CB5

無効なアカウントの場合(メールアドレス間違い、パスワード間違いなど)

login
> curl -i -b cookie.txt -c cookie.txt -X POST "http://localhost:9000/app/login" -d "email=kkamimura@example.com" -d "pass=hogehoge" -d "_csrf={CSRF-TOKEN}"

HTTP/1.1 401

認証されているユーザーであればアクセスできるAPI

認証済みユーザーの場合

> curl -i -b cookie.txt "http://localhost:9000/app/memo/1"

HTTP/1.1 200

未認証ユーザーの場合

> curl -i "http://localhost:9000/app/memo/1"

HTTP/1.1 401

認証とUSERロールが必要なAPI

USERロールを持つユーザーの場合

> curl -i -b cookie.txt "http://localhost:9000/app/user"

HTTP/1.1 200

Request BodyにCSRFトークンを指定できない場合はヘッダーに指定します。

> curl -i -b cookie.txt -H "Content-Type:application/json" -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/user/echo" -d "{\"message\": \"abc\"}"

HTTP/1.1 200

未認証ユーザーの場合

> curl -i "http://localhost:9000/app/user"

HTTP/1.1 401

認証とADMINロールが必要なAPI

ADMINロールを持つユーザーの場合

> curl -i -b cookie.txt "http://localhost:9000/app/admin"

HTTP/1.1 200
> curl -i -b cookie.txt -H "Content-Type:application/json" -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/admin/echo" -d "{\"message\": \"abc\"}"

HTTP/1.1 200

無効なCSRFトークンの場合

> curl -i -b cookie.txt -H "Content-Type:application/json" -H "x-xsrf-token:{INVALID-CSRF-TOKEN}" -X POST "http://localhost:9000/app/admin/echo" -d "{\"message\": \"abc\"}"

HTTP/1.1 403

ADMINロールを持たないユーザーの場合
(ADMINロールを持たないユーザーでログインしてから確認します。)

> curl -i -b cookie.txt "http://localhost:9000/app/admin"

HTTP/1.1 403

未認証ユーザーの場合

> curl -i "http://localhost:9000/app/admin"

HTTP/1.1 401

ログアウトAPI

認証済みユーザーの場合

logout
> curl -i -b cookie.txt -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/logout"

HTTP/1.1 200

未認証ユーザーの場合

logout
> curl -i -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/logout"

HTTP/1.1 401

無効なCSRFトークンの場合

logout
> curl -i -b cookie.txt -H "x-xsrf-token:{INVALID-CSRF-TOKEN}" -X POST "http://localhost:9000/app/logout"

HTTP/1.1 403

テストコードの説明

Spring Securityを利用したアプリケーションのコントローラクラスの単体、結合テストについて説明します。

コントローラの単体テスト

この記事ではMockMvcTestアノテーションを使ったテストを単体テストとしています。
単体テストで混乱を誘うのが基本的な認証は有効になっているのに、SecurityConfigでカスタマイズした挙動は反映されていない点です。
単体テストでセキュリティに関する部分(SecurityConfigクラスで設定した内容)もテストするかどうかでテストコードの設定が変わります。

Spring Securityの機能を無効化する

セキュリティに関する部分を考慮せずにテストしてもいい場合はWebMvcTestアノテーションのsecure属性にfalseを指定してSpring Securityの機能を無効化できます。

@RunWith(SpringRunner.class)
@WebMvcTest(value = UserController.class, secure = false)
public class UserControllerTests {

    // テストコード

}

この設定であれば認証が必要なAPIは認証せずにテストできます。

@Test
public void getEcho() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc")
        .accept(MediaType.TEXT_PLAIN);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(content().contentType(contentTypeText))
        .andExpect(content().string("ABC"))
        .andDo(print());
}

ただし、ハンドラメソッドがユーザーの認証オブジェクトを引数に取る場合は、認証オブジェクトがインジェクトできなくなるのでテストできません。

認証オブジェクトを引数に取る例
@GetMapping
public String greeting(@AuthenticationPrincipal(expression = "user") User user) {

}

Spring Securityの機能を一部有効にする

デフォルトの状態(secure = true)です。認証の部分は有効になっていますが、SecurityConfigの設定が反映されていないので認可の部分は無効です。たとえばロールによるアクセス制限を行っていてもテスト上はどのロールでもアクセスできてしまいます。

@RunWith(SpringRunner.class)
@WebMvcTest(value = UserController.class, secure = true)
public class UserControllerTests {

    // テストコード

}

テスト対象のコントローラのハンドラメソッドがユーザーの認証オブジェクトを引数に取っている場合は、ダミーの認証オブジェクトをwith(user(...))で指定できます。
SecurityConfigでCSRFトークンをクッキーに保存するようにしていますが、テストでは反映されていないのでXSRF-TOKENクッキーは存在しません。

@Test
public void greeting() throws Exception {
    // ダミーの認証オブジェクトを生成
    User user = new User(1L, "test_user", "pass", "aaa.aaa@example.com", true);
    SimpleLoginUser loginUser = new SimpleLoginUser(user);

    RequestBuilder builder = MockMvcRequestBuilders.get("/user")
        .with(user(loginUser))
        .accept(MediaType.TEXT_PLAIN);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(authenticated().withUsername("test_user").withRoles("USER", "ADMIN"))
        .andExpect(content().contentType(contentTypeText))
        .andExpect(content().string("hello aaaa"))
        //.andExpect(cookie().exists("XSRF-TOKEN"))
        .andExpect(forwardedUrl(null))
        .andExpect(redirectedUrl(null))
        .andDo(print());
    }

認証が必要なAPIでハンドラメソッドが認証オブジェクトを引数に取らない場合は、WithMockUserアノテーションを指定するだけで済みます。

@WithMockUser(roles = "USER")
@Test
public void getEcho() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc")
        .accept(MediaType.TEXT_PLAIN);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(content().contentType(contentTypeText))
        .andExpect(content().string("ABC"))
        .andDo(print());
}

WithMockUserアノテーションを付けないと未認証の状態なのでHTTPステータス401が返ります。

@Test
public void getEcho_401() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc");

    mvc.perform(builder)
        .andExpect(status().is(HttpStatus.UNAUTHORIZED.value()))
        .andDo(print());
}

ただし、認可の設定は有効になっていないので次のテストは失敗します。
このテスト対象のAPIはUSERロールを持つユーザーでなければアクセスできないのですが、ADMINロールを指定したユーザーでもアクセスできてしまいます。

@WithMockUser(roles = "ADMIN")
@Test
public void getEcho_403() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc");

    mvc.perform(builder)
        .andExpect(status().is(HttpStatus.FORBIDDEN.value()))
        .andDo(print());
}

POSTメソッドなどCSRFトークンが必要なAPIはwith(csrf())を指定します。
無効なCSRFトークンを使いたい場合はwith(csrf().useInvalidToken())とします。

@WithMockUser(roles = "USER")
@Test
public void postEcho() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.post("/user/echo")
        .contentType(MediaType.APPLICATION_JSON_UTF8)
        .content("{\"message\": \"hello world\"}")
        .with(csrf())
        .accept(MediaType.APPLICATION_JSON_UTF8_VALUE);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(content().contentType(contentTypeJson))
        .andExpect(content().string("{message=hello world}"))
        .andDo(print());
}

認証不要なAPIは認証が必要になっているので注意が必要です。
このテスト対象のAPIはSecurityConfigで認証不要なAPIとして定義していますが、テストではWithMockUserアノテーションを付けないと失敗します。

@RunWith(SpringRunner.class)
@WebMvcTest(value = HelloController.class)
public class HelloControllerTests {

    @Autowired
    private MockMvc mvc;

    final private MediaType contentTypeText = new MediaType(MediaType.TEXT_PLAIN.getType(),
            MediaType.TEXT_PLAIN.getSubtype(), Charset.forName("utf8"));

    @WithMockUser
    @Test
    public void greeting() throws Exception {
        RequestBuilder builder = MockMvcRequestBuilders.get("/hello")
            .accept(MediaType.TEXT_PLAIN);

        mvc.perform(builder)
            .andExpect(status().isOk())
            .andExpect(content().contentType(contentTypeText))
            .andExpect(content().string("hello world"))
            .andDo(print());
    }

}

SecurityConfigの設定を反映させる

単体テストの要件にSpringConfigで設定した内容も含まれる場合は、SecurityConfigクラスをインポートします。ここまですると結合テストとほぼ同じ条件でのテストになるのでテストの詳細度によっては結合テストにする方がいいかもしれません。

SecurityConfigをインポートするとUserDetailsServiceインターフェースを実装したクラスのインスタンスが必要になるのでMockBeanでモック化したインスタンスを準備しています。

@RunWith(SpringRunner.class)
@WebMvcTest(value = UserController.class)
@Import(value = {SecurityConfig.class})
public class UserControllerTests {

    @Autowired
    private MockMvc mvc;

    @MockBean(name = "simpleUserDetailsService")
    private UserDetailsService userDetailsService;

    // テストコード

}

上記の「Spring Securityの機能を一部有効にする」で失敗した認可のテストも成功するようになります。
アクセスできるロールを持たないユーザーの場合はHTTPステータス403が返るようになります。

@WithMockUser(roles = "ADMIN")
@Test
public void getEcho_403() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc");

    mvc.perform(builder)
        .andExpect(status().is(HttpStatus.FORBIDDEN.value()))
        .andDo(print());
}

コントローラの結合テスト

この記事ではSpringBootTestアノテーションを使ったテストを結合テストとしています。
結合テストではSecurityConfigクラスの設定内容はデフォルトで有効になっています。

単体テストではMockMvcはautowiredしていましたが、下記のコードのようにBeforeアノテーションを付けたメソッドでビルドする必要があるようです。

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserControllerIntegrationTests {

    @Autowired
    private WebApplicationContext context;

    private MockMvc mvc;

    @Before
    public void setup() {
        mvc = MockMvcBuilders
            .webAppContextSetup(context)
            .apply(springSecurity())
            .build();
    }

    // テストコード

}

テストコードは「SecurityConfigの設定を反映させる」のときの単体テストのコードとほぼ同じです。

@WithMockUser(roles = "USER")
@Test
public void getEcho() throws Exception {
    RequestBuilder builder = MockMvcRequestBuilders.get("/user/echo/{message}", "abc")
        .accept(MediaType.TEXT_PLAIN);

    mvc.perform(builder)
        .andExpect(status().isOk())
        .andExpect(content().contentType(contentTypeText))
        .andExpect(content().string("ABC"))
        .andDo(print());
}