概要
この記事は、以前に投稿した「[Spring Security with Spring Boot 2.0.1で簡単なRest APIを実装する] (https://qiita.com/rubytomato@github/items/6c6318c948398fa62275)」で利用したデモアプリケーションをJWT(Json Web Token)に対応させたときの変更点を説明する記事です。
JWTの仕様については詳しい記事がたくさんありますのでここでは触れません。
ソースコードは[rubytomato/demo-security-jwt-spring2] (https://github.com/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
参考
- [Spring Security Reference] (https://docs.spring.io/spring-security/site/docs/5.0.4.RELEASE/reference/htmlsingle/)
- [auth0/java-jwt] (https://github.com/auth0/java-jwt)
- Java implementation of JSON Web Token (JWT)
- [murraco/spring-boot-jwt] (https://github.com/murraco/spring-boot-jwt)
- [Securing Spring Boot with JWTs] (https://auth0.com/blog/securing-spring-boot-with-jwts/)
- [JSON Web Tokens - jwt.io] (https://jwt.io/)
- JWT.IO allows you to decode, verify and generate JWT.
デモアプリケーションの要件
認証の仕方
このデモアプリケーションは、メールアドレス・パスワードで認証を行います。具体的にはログイン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] (https://jwt.io/)というサイトでトークンをデコードすることができます。
トークンの検証
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();
}
}
認証と成功・失敗時の処理
認証周りの設定自体に変更はありませんでした。
.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ヘッダーにトークンをセットしています。
@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)より前に実行するように設定します。
.addFilterBefore(tokenFilter(), UsernamePasswordAuthenticationFilter.class)
SimpleTokenFilter
GenericFilterBean tokenFilter() {
return new SimpleTokenFilter(userRepository, secretKey);
}
リクエストヘッダー"Authorization"からトークンを取得し検証します。検証ではトークンが改ざんされていないかの他に設定した有効期限のチェックも行われます。
トークンが正常であればペイロードからユーザー情報を検索するためのユーザーIDを取得してユーザーを検索します。
@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ステータスを返すだけの処理になっています。
.logout()
.logoutUrl("/logout")
//.invalidateHttpSession(true)
//.deleteCookies("JSESSIONID")
.logoutSuccessHandler(logoutSuccessHandler())
トークンの無効化
無効化をサーバー側で対処する場合は、ログアウトしたユーザーのトークンを一定期間(トークンの有効期限まで)保持しておき、認可処理のときに突き合わせて一致した場合は認可しないといった方法があるようです。
CSRF
CSRFは利用しないので無効にしました。
.csrf()
.disable()
//.ignoringAntMatchers("/login")
//.csrfTokenRepository(new CookieCsrfTokenRepository())
SessionManagement
セッション管理は行わないのでステートレスに設定しました。この設定にするとSpring SecurityがHttpSessionを利用することはありません。
.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