概要
Spring SecurityとSpring Bootを利用して簡単なRest APIのデモアプリケーションを実装しました。
記事の前半はSpring Security周りの実装、後半がコントローラの実装とそのテストコードについて説明しています。
ソースコードは[rubytomato/demo-security-spring2] (https://github.com/rubytomato/demo-security-spring2)にあります。
環境
- Windows 10 Professional
- Java 1.8.0_172
- Spring Boot 2.0.2
- Spring Security 5.0.5
参考
- [Spring Security Reference] (https://docs.spring.io/spring-security/site/docs/5.0.4.RELEASE/reference/htmlsingle/)
- [Hello Spring Security with Boot] (https://docs.spring.io/spring-security/site/docs/5.0.4.RELEASE/guides/html5//helloworld-boot.html)
- [Spring Boot Reference Guide] (https://docs.spring.io/spring-boot/docs/2.0.1.RELEASE/reference/htmlsingle/)
デモアプリケーションの要件
認証の仕方
このデモアプリケーションは、メールアドレス・パスワードで認証を行います。具体的にはログイン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();
}
}
認可の設定
.authorizeRequests()
.mvcMatchers("/prelogin", "/hello/**")
.permitAll()
.mvcMatchers("/user/**")
.hasRole("USER")
.mvcMatchers("/admin/**")
.hasRole("ADMIN")
.anyRequest()
.authenticated()
APIの認可の設定を行います。
- permitAll()は、認証、ロールに関係なく許可します。
- hasRole()は、認証されたユーザーが指定されたロールを持つ場合に許可します。
- authenticated()は、認証されたユーザーの場合に許可します。
認証、認可の例外処理
.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint())
.accessDeniedHandler(accessDeniedHandler())
authenticationEntryPoint()
[15.2.1 AuthenticationEntryPoint] (https://docs.spring.io/spring-security/site/docs/5.0.4.RELEASE/reference/htmlsingle/#auth-entry-point)
未認証のユーザーが認証の必要なAPIにアクセスしたときの処理を設定します。
AuthenticationEntryPoint authenticationEntryPoint() {
return new SimpleAuthenticationEntryPoint();
}
デフォルトや用意されている標準実装クラスは利用せず、HTTPステータス401とデフォルトのメッセージを返すだけの処理を実装します。
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] (https://docs.spring.io/spring-security/site/docs/5.0.4.RELEASE/reference/htmlsingle/#access-denied-handler)
ユーザーは認証済みだが未認可のリソースへアクセスしたときの処理を設定します。
AccessDeniedHandler accessDeniedHandler() {
return new SimpleAccessDeniedHandler();
}
デフォルトや用意されている標準実装クラスは利用せず、HTTPステータス403とデフォルトのメッセージを返すだけのハンドラを実装します。
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トークンが見つからない場合にスローされます
- InvalidCsrfTokenException
- CSRFトークンが無効な場合にスローされます
認証と成功・失敗時の処理
[15.4.1 Application Flow on Authentication Success and Failure] (https://docs.spring.io/spring-security/site/docs/5.0.4.RELEASE/reference/htmlsingle/#form-login-flow-handling)
.formLogin()
.loginProcessingUrl("/login").permitAll()
.usernameParameter("email")
.passwordParameter("pass")
.successHandler(authenticationSuccessHandler())
.failureHandler(authenticationFailureHandler())
loginProcessingUrl()
ログインページとパラメータ名の設定を行います。このページへのアクセスに認証は不要とします。(permitAll)
successHandler()
認証が成功した時の処理を実装したハンドラを設定します。
AuthenticationSuccessHandler authenticationSuccessHandler() {
return new SimpleAuthenticationSuccessHandler();
}
デフォルトや用意されている標準実装クラスは利用せず、HTTPステータス200を返すだけのハンドラを実装します。
@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とデフォルトのメッセージを返すだけのハンドラを実装します。
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
ログアウト時の処理
.logout()
.logoutUrl("/logout")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
.logoutSuccessHandler(logoutSuccessHandler())
//.addLogoutHandler(new CookieClearingLogoutHandler())
logoutUrl()
ログアウトページの設定を行います。
logoutSuccessHandler()
ログアウトが正常終了した時の処理を実装したハンドラを設定します。
HTTPステータスを返すだけのSpring Securityの標準実装クラスHttpStatusReturningLogoutSuccessHandlerがあるのでこれを利用しました。
ログアウト時に行うセッション破棄やクッキー削除はコンフィグレーションで行うので実装は不要です。
[5.5.2 LogoutSuccessHandler] (https://docs.spring.io/spring-security/site/docs/5.0.4.RELEASE/reference/htmlsingle/#jc-logout-success-handler)
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を指定します。
.csrf()
//.ignoringAntMatchers("/login")
.csrfTokenRepository(new CookieCsrfTokenRepository())
CSRF対策を無効にしたい場合はdisableを追加します。
.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
キャッシュコントロールを無効にしたい場合
.headers()
.cacheControl()
.disable()
他のオプションも無効にしたい場合
.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] (https://docs.spring.io/spring-security/site/docs/5.0.4.RELEASE/reference/htmlsingle/#tech-userdetailsservice)
[10.2 UserDetailsService Implementations] (https://docs.spring.io/spring-security/site/docs/5.0.4.RELEASE/reference/htmlsingle/#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] (https://docs.spring.io/spring-security/site/docs/5.0.4.RELEASE/reference/htmlsingle/#tech-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エンティティのインスタンスを定義しています。
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] (https://docs.spring.io/spring-security/site/docs/5.0.4.RELEASE/reference/htmlsingle/#core-services-password-encoding)
パスワードのエンコードは標準実装クラスのBCryptPasswordEncoderを利用しました。他にも標準実装クラスがいくつかありますが、アプリケーションの要件に合わなければ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] (https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#mvc)
コントローラの引数に
- 認証されているユーザーの認証情報を受け取れます。
- 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} |
@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トークンが必要です。
> curl -i -X POST "http://localhost:9000/app/hello" -d "message=WORLD"
HTTP/1.1 401
> 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です。
> curl -i -c cookie.txt "http://localhost:9000/app/prelogin"
HTTP/1.1 200
{CSRF-TOKEN}
ログインAPI
先にプレログインAPIにアクセスしてログイン時に使用するCSRF-TOKENを取得しておきます。
有効なアカウントの場合
> 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
無効なアカウントの場合(メールアドレス間違い、パスワード間違いなど)
> 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
認証済みユーザーの場合
> curl -i -b cookie.txt -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/logout"
HTTP/1.1 200
未認証ユーザーの場合
> curl -i -H "x-xsrf-token:{CSRF-TOKEN}" -X POST "http://localhost:9000/app/logout"
HTTP/1.1 401
無効なCSRFトークンの場合
> 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());
}