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

More than 1 year has passed since last update.


概要

この記事は、以前に投稿した「Spring Security with Spring Boot 2.0.1で簡単なRest APIを実装する」で利用したデモアプリケーションをJWT(Json Web Token)に対応させたときの変更点を説明する記事です。

JWTの仕様については詳しい記事がたくさんありますのでここでは触れません。

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

環境


  • Windows 10 Professional

  • Java 1.8.0_172

  • Spring Boot 2.0.2

  • Spring Security 5.0.5


    • java-jwt 3.3.0



参考


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


認証の仕方

このデモアプリケーションは、メールアドレス・パスワードで認証を行います。具体的にはログインAPIにメールアドレスとパスワードをPOSTリクエストし、認証できればHTTPステータス200とJWTのトークンが返ります。

以降の認証、認可はリクエストヘッダーにセットしたJWTのトークンによって行います。


セキュリティの実装(JWT対応で変更のあった箇所)


依存関係の追加

複数あるJWTを扱うライブラリの中からjava-jwtを選びました。

<dependency>

<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.3.0</version>
</dependency>


java-jwtの簡単な使い方


トークンのペイロードについて

トークンのペイロード(claim)の識別子には予約済み(Registered Claim Names)のものがあります。いずれもオプショナルの扱いなので設定するかどうかは利用するアプリケーションに依存します。

id
name
description

jti
JWT ID
JWTの一意の識別子

aud
Audience
JWTの利用者

iss
Issuer
JWTの発行者

sub
Subject
JWTの主体. JWTの発行者のコンテキスト内でユニークまたはグローバルユニークな値

iat
Issued At
JWTの発行時間

nbf
Not Before
JWTの有効期間の開始時間. この時間より前には利用できない

exp
Expiration Time
JWTの有効期間の終了時間. この時間より後には利用できない


トークンの生成

private static final Long EXPIRATION_TIME = 1000L * 60L * 10L;

public void build() {
String secretKey = "secret";
Date issuedAt = new Date();
Date notBefore = new Date(issuedAt.getTime());
Date expiresAt = new Date(issuedAt.getTime() + EXPIRATION_TIME);

try {
Algorithm algorithm = Algorithm.HMAC256(secretKey);
String token = JWT.create()
// registered claims
//.withJWTId("jwtId") //"jti" : JWT ID
//.withAudience("audience") //"aud" : Audience
//.withIssuer("issuer") //"iss" : Issuer
.withSubject("test") //"sub" : Subject
.withIssuedAt(issuedAt) //"iat" : Issued At
.withNotBefore(notBefore) //"nbf" : Not Before
.withExpiresAt(expiresAt) //"exp" : Expiration Time
//private claims
.withClaim("X-AUTHORITIES", "aaa")
.withClaim("X-USERNAME", "bbb")
.sign(algorithm);
System.out.println("generate token : " + token);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}

この設定で生成されるトークンは以下のようになります。

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0IiwibmJmIjoxNTIzNTA2NzQwLCJYLUFVVEhPUklUSUVTIjoiYWFhIiwiZXhwIjoxNTIzNTA3MzQwLCJpYXQiOjE1MjM1MDY3NDAsIlgtVVNFUk5BTUUiOiJiYmIifQ.KLwUQcuNEt7m1HAC6ZzzGtRjZ3a2kvY11732aP9dyDY

JSON Web Tokens - jwt.ioというサイトでトークンをデコードすることができます。

d.png


トークンの検証

public void verify() {

String secretKey = "secret";
String token = "";

try {
Algorithm algorithm = Algorithm.HMAC256(secretKey);
JWTVerifier verifier = JWT.require(algorithm).build();

DecodedJWT jwt = verifier.verify(token);

// registered claims
String subject = jwt.getSubject();
Date issuedAt = jwt.getIssuedAt();
Date notBefore = jwt.getNotBefore();
Date expiresAt = jwt.getExpiresAt();
System.out.println("subject : [" + subject + "] issuedAt : [" + issuedAt.toString() + "] notBefore : [" + notBefore.toString() + "] expiresAt : [" + expiresAt.toString() + "]");
// subject : [test] issuedAt : [Thu Apr 12 13:19:00 JST 2018] notBefore : [Thu Apr 12 13:19:00 JST 2018] expiresAt : [Thu Apr 12 13:29:00 JST 2018]

// private claims
String authorities = jwt.getClaim("X-AUTHORITIES").asString();
String username = jwt.getClaim("X-USERNAME").asString();
System.out.println("private claim X-AUTHORITIES : [" + authorities + "] X-USERNAME : [" + username + "]");
// private claim X-AUTHORITIES : [aaa] X-USERNAME : [bbb]

} catch (UnsupportedEncodingException | JWTVerificationException e) {
e.printStackTrace();
}
}


verify時に発生し得る例外

すべて網羅していませんが起こりやすい(起こしやすい)例外には以下のものがあります。

いずれの例外もJWTVerificationExceptionのサブクラスです。

exception
description

SignatureVerificationException
シークレットキーが異なる場合など

AlgorithmMismatchException
署名アルゴリズムが異なる場合など

JWTDecodeException
トークンが改ざんされている場合など

TokenExpiredException
トークンの有効期限が切れいている場合

InvalidClaimException
トークンの利用開始前の場合など


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

JWT対応で変更した箇所を示します。


  • ★1 署名時に使用するシークレットキーを設定ファイルから取り込みます

  • ★2 ログアウト時のセッション破棄やクッキー削除は無用になりました

  • ★3 CSRF対策を無効にしました

  • ★4 認可処理を行うフィルターを追加しました

  • ★5 セッションをステートレスに変更しました

@EnableWebSecurity

public class SecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private UserRepository userRepository;

// ★1
@Value("${security.secret-key:secret}")
private String secretKey = "secret";

@Override
protected void configure(HttpSecurity http) throws Exception {
// @formatter:off
http
// AUTHORIZE
.authorizeRequests()
.mvcMatchers("/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()
// ★2 LOGOUT
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(logoutSuccessHandler())
.and()
// ★3 CSRF
.csrf()
.disable()
// ★4 AUTHORIZE
.addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)
// ★5 SESSION
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
;
// @formatter:on
}

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

@Bean("simpleUserDetailsService")
UserDetailsService simpleUserDetailsService() {
return new SimpleUserDetailsService(userRepository);
}

@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

GenericFilterBean tokenFilter() {
return new SimpleTokenFilter(userRepository, secretKey);
}

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

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

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

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

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

}


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

認証周りの設定自体に変更はありませんでした。


configuration

.formLogin()

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


successHandler()

変更前はHTTPステータス200を返すだけのハンドラでしたが、認証情報からトークンを生成しレスポンスヘッダーへセットするように変更しました。

AuthenticationSuccessHandler authenticationSuccessHandler() {

return new SimpleAuthenticationSuccessHandler(secretKey);
}

トークンのペイロード(claim)に設定する情報は誰にでもデコードできるので外部に露出しても問題ない情報を選びます。

この例ではsubjectにユーザーIDをセットし、認可時はトークンからユーザーIDを取得してユーザーを検索するという処理になります。

String token = JWT.create()

.withIssuedAt(issuedAt) // JWTの発行時間
.withNotBefore(notBefore) // JWTの有効期限の開始時間
.withExpiresAt(expiresAt) // JWTの有効期限の終了時間
.withSubject(loginUser.getUser().getId().toString()) // JWTの主体、JWTの発行者のコンテキスト内でユニークまたはグローバルユニークな値
.sign(this.algorithm);

generateTokenメソッドで認証情報からトークンを生成し、setTokenメソッドでAuthorizationヘッダーにトークンをセットしています。


SimpleAuthenticationSuccessHandler

@Slf4j

public class SimpleAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

final private Algorithm algorithm;

public SimpleAuthenticationSuccessHandler(String secretKey) {
Objects.requireNonNull(secretKey, "secret key must be not null");
try {
this.algorithm = Algorithm.HMAC256(secretKey);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}

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

private static final Long EXPIRATION_TIME = 1000L * 60L * 10L;

private String generateToken(Authentication auth) {
SimpleLoginUser loginUser = (SimpleLoginUser) auth.getPrincipal();
Date issuedAt = new Date();
Date notBefore = new Date(issuedAt.getTime());
Date expiresAt = new Date(issuedAt.getTime() + EXPIRATION_TIME);
String token = JWT.create()
.withIssuedAt(issuedAt)
.withNotBefore(notBefore)
.withExpiresAt(expiresAt)
.withSubject(loginUser.getUser().getId().toString())
.sign(this.algorithm);
log.debug("generate token : {}", token);
return token;
}

private void setToken(HttpServletResponse response, String token) {
response.setHeader("Authorization", String.format("Bearer %s", token));
}

/**
* 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);
}

}



トークンの検証と認可

トークンの検証と認可を行うフィルターを新しく実装しました。

このフィルターは、ユーザー名・パスワードで認証を行うフィルター(UsernamePasswordAuthenticationFilter)より前に実行するように設定します。


configuration

.addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)



SimpleTokenFilter

GenericFilterBean tokenFilter() {

return new SimpleTokenFilter(userRepository, secretKey);
}

リクエストヘッダー"Authorization"からトークンを取得し検証します。検証ではトークンが改ざんされていないかの他に設定した有効期限のチェックも行われます。

トークンが正常であればペイロードからユーザー情報を検索するためのユーザーIDを取得してユーザーを検索します。


SimpleTokenFilter

@Slf4j

public class SimpleTokenFilter extends GenericFilterBean {

final private UserRepository userRepository;
final private Algorithm algorithm;

public SimpleTokenFilter(UserRepository userRepository, String secretKey) {
Objects.requireNonNull(secretKey, "secret key must be not null");
this.userRepository = userRepository;
try {
this.algorithm = Algorithm.HMAC256(secretKey);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
String token = resolveToken(request);
if (token == null) {
filterChain.doFilter(request, response);
return;
}

try {
authentication(verifyToken(token));
} catch (JWTVerificationException e) {
log.error("verify token error", e);
SecurityContextHolder.clearContext();
((HttpServletResponse) response).sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
}
filterChain.doFilter(request, response);
}

private String resolveToken(ServletRequest request) {
String token = ((HttpServletRequest) request).getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
return null;
}
return token.substring(7);
}

private DecodedJWT verifyToken(String token) {
JWTVerifier verifier = JWT.require(algorithm).build();
return verifier.verify(token);
}

private void authentication(DecodedJWT jwt) {
Long userId = Long.valueOf(jwt.getSubject());
userRepository.findById(userId).ifPresent(user -> {
SimpleLoginUser simpleLoginUser = new SimpleLoginUser(user);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(simpleLoginUser, null, simpleLoginUser.getAuthorities()));
});
}

}



ログアウト時の処理

ログアウト時の設定はURLとハンドラだけになりました。

ログアウト時に発行したトークンを無効化したかったのですが、調べた範囲ではトークンを無効化することはできないようなのでハンドラはHTTPステータスを返すだけの処理になっています。


configuration

.logout()

.logoutUrl("/logout")
//.invalidateHttpSession(true)
//.deleteCookies("JSESSIONID")
.logoutSuccessHandler(logoutSuccessHandler())


トークンの無効化

無効化をサーバー側で対処する場合は、ログアウトしたユーザーのトークンを一定期間(トークンの有効期限まで)保持しておき、認可処理のときに突き合わせて一致した場合は認可しないといった方法があるようです。


CSRF

CSRFは利用しないので無効にしました。


configuration

.csrf()

.disable()
//.ignoringAntMatchers("/login")
//.csrfTokenRepository(new CookieCsrfTokenRepository())


SessionManagement

セッション管理は行わないのでステートレスに設定しました。この設定にするとSpring SecurityがHttpSessionを利用することはありません。


configuration

.sessionManagement()

.sessionCreationPolicy(SessionCreationPolicy.STATELESS)


APIの動作確認

JWTに変更したことによりセッションクッキーもCSRFトークンも不要になりました。代わりに認証後に受け取ったJWTのトークンをリクエストヘッダーにセットします。


ログインAPI

有効なアカウントの場合

認証に成功すると下記の例のとおり、Authorizationヘッダーにトークンがセットされます。

> curl -i -X POST "http://localhost:9000/app/login" -d "email=kkamimura@example.com" -d "pass=iWKw06pvj"

HTTP/1.1 200
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwibmJmIjoxNTIzNTExNjY5LCJleHAiOjE1MjM1MTIyNjksImlhdCI6MTUyMzUxMTY2OX0.E6HZShowNPUvNj84dYRHMyZROxIvYjsEP7e29
_QLXic
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
Content-Length: 0
Date: Thu, 12 Apr 2018 05:41:09 GMT

無効なアカウントの場合

メールアドレス間違い、パスワード間違いなど

> curl -i -X POST "http://localhost:9000/app/login" -d "email=kkamimura@example.com" -d "pass=hogehoge"

HTTP/1.1 401


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

有効なトークンを指定した場合

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 200

トークンを指定しない場合

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

HTTP/1.1 401

トークンを改ざんした場合

> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 401

トークンの有効期限が切れている場合

> curl -i -H "Authorization: Bearer {invalid_token}" "http://localhost:9000/app/memo/1"

HTTP/1.1 401


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

USERロールを持っているユーザーの場合

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/user"

HTTP/1.1 200


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

ADMINロールを持っているユーザーの場合

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"

HTTP/1.1 200

ADMINロールをもっていないユーザーの場合

トークンが正常でもユーザーが必要なロールを持っていない場合はエラーになります。

> curl -i -H "Authorization: Bearer {valid_token}" "http://localhost:9000/app/admin"

HTTP/1.1 403