JWTの認証・認可プロセスをリフレッシュトークンを含めて実装・解説した記事があまりなかったのでまとめてみました。
GitHubにコードを置いてあります: https://github.com/otoiku/jwt_with_refreshtoken
#参考にしたところ
Refresh Token: どのような場合に使用し、どのように JWT と相互作用するか
JWTを利用したSpringアプリのAPI認証
Spring Boot Security Example - Refresh Expired JSON Web Token
JWT認証とは
Cookie+サーバ側でセッション管理するのではなく、クライアントに対してJSON形式のトークン(JSON Web Token)を発行し、そのトークン自体に認証情報と電子署名を持たせてしまう方式です。クライアントはトークンを毎回Authorizationヘッダに入れてリクエストを送ることで、サーバ側で状態管理をする必要がなくなります(ステートレス)。トークンが本物かどうかは電子署名で確認します。実際にはトークンはBase64でエンコードされているのですが、例えば https://jwt.io/ でデコードして中身を確認できます。
認証の流れはこんな感じになります。(この後出てくるリフレッシュトークンと紛らわしいので、これからアクセストークンと呼ぶことにします。)
- クライアントはユーザIDとパスワードをサーバに送る。
- サーバが認証を行い、OKだったらアクセストークンを発行しクライアントに送る。
- クライアントはリクエストのたびにアクセストークンも一緒に送る。
- サーバがアクセストークンを復号して認証・認可する。
セキュリティの観点からアクセストークンの有効期限は短めに設定します(例えば20分)。ただし、これだとユーザは頻繁に認証しなおさなければなくなるため不便です。そのため、アクセストークンとは別にリフレッシュトークンというものも発行し、アクセストークンの有効期限が切れてもリフレッシュトークンで新しいアクセストークンを取得しなおせるようにします。リフレッシュトークンの有効期限はアクセストークンよりも長く設定します(数週間とか数か月とか)。
従って、リフレッシュトークンを含めたプロセスはこんな感じになります。
- クライアントはユーザIDとパスワードをサーバに送る。
- サーバが認証を行い、OKだったらアクセストークン***&リフレッシュトークン***を発行しクライアントに送る。
- クライアントはリクエストのたびにアクセストークンも一緒に送る。
- サーバがアクセストークンを復号して認証・認可する。
- アクセストークンの有効期限が切れサーバがエラーを返した場合:クライアントはリフレッシュトークンをサーバに送ってアクセストークンの再発行を行う。
ステップ5.に関してはクライアントアプリケーションがバックグラウンドで行うことで、ユーザはアクセストークンの有効期限切れを意識する必要がありません。
また、リフレッシュトークンのフォーマットに決まりはないみたいで、きちんと管理さえしてあればランダムな文字列でいいみたいです。今回はそれで実装してみることにします。
環境
Spring Initializrでアプリケーションのテンプレートを作成しました。
- Spring Web
- Spring Security
- Spring Data JPA
- Lombok
そしていくつか依存関係を加えてます。
-
com.auth0:java-jwt
JWTの操作用 -
org.apache.commons:commons-lang3
文字列操作とかランダム文字列の生成とか -
com.h2database:h2
今回はH2のインメモリデータベースを使用
plugins {
id 'org.springframework.boot' version '2.4.2'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.auth0:java-jwt:3.12.0'
implementation 'org.apache.commons:commons-lang3:3.11'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2:1.4.196'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
実行例
/loginエンドポイントでユーザ認証&トークンの発行。
curl -X POST -d "{ \"userId\" : \"test\", \"password\" : \"abc\"}" -H "accept: application/json" -H "Content-Type: application/json" "http://localhost:8080/login"
{
"accessToken":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjoxNjEyMDI4ODIwfQ.GHpZLa0-6K7veKJ0hxzLIepDvZxczbOsFynpDbZS7qfXA2D4kGow_E7nlnIjqawXiIc6FOefETS9ts7AaHrN5A",
"refreshToken":"xxYlzCt0itmT6GVXt3VwtXJy"
}
クライアントはAuthorizationリクエストヘッダーにアクセストークンを毎回入れる。
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjoxNjEyMDI4ODIwfQ.GHpZLa0-6K7veKJ0hxzLIepDvZxczbOsFynpDbZS7qfXA2D4kGow_E7nlnIjqawXiIc6FOefETS9ts7AaHrN5A
/api/refreshTokenエンドポイントでトークンの再発行。アクセストークンの有効期限が切れてても、リフレッシュトークンが本人のもので期限内であればOKとします。
curl -X POST -d "{\"accessToken\": \"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjoxNjEyMDI4ODIwfQ.GHpZLa0-6K7veKJ0hxzLIepDvZxczbOsFynpDbZS7qfXA2D4kGow_E7nlnIjqawXiIc6FOefETS9ts7AaHrN5A\",\"refreshToken\": \"xxYlzCt0itmT6GVXt3VwtXJy\"}" -H "accept: application/json" -H "Content-Type: application/json" "http://localhost:8080/api/refreshToken"
{
"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjoxNjExOTY1ODM1fQ.AUMJl2ES-_FUYzAMiF-0Xf8UElFniNENwjrc4EhmDfy1hEMb-MqGAMcWQ04hmPfPJ9PLUN0u31Aa_tHgVua6HA",
"refreshToken": "uB72EurLJtphrcMM3AGH3jLV"
}
構築
アプリケーションのフォルダ構造はこんな感じです。
./
├─ src/
│ ├─ main/
│ │ └─ com.github.otoiku.jwt_with_refreshtoken/
│ │ ├─ controller/
│ │ │ └─ Controller
│ │ ├─ dto/
│ │ │ ├─ UserAuthentification
│ │ │ └─ UserIssueToken
│ │ ├─ model/
│ │ │ └─ User
│ │ ├─ repository/
│ │ │ └─ UserRepository
│ │ ├─ security/
│ │ │ ├─ JwtAuthorizationFilter
│ │ │ ├─ JwtAuthenticationFilter
│ │ │ └─ SecurityConfig
│ │ ├─ service/
│ │ │ └─ JwtUserDetailsService
│ │ └─ Application
│ └─ resources/
│ ├─ application.properties
│ └─ schema.sql
├─ build.gradle
└─ settings.gradle
application.properties
jwt.accesstoken.secretkeyはJWTの電子署名用の共通鍵。コードを変更すれば公開鍵方式にもできます。
jwt.accesstoken.expirationtimeとjwt.refreshtoken.expirationtimeはそれぞれのトークンの有効期間(秒)です。(テスト用に短く設定してます。)
残りはH2データベースを起動するための設定です。
### JWT configuration
jwt.accesstoken.secretkey=MY_SECRET_KEY!
jwt.accesstoken.expirationtime=30
jwt.refreshtoken.expirationtime=60
### Datasource configuration
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.initialization-mode=always
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=none
spring.h2.console.enabled=true
schema.sql
ユーザの認証情報(ユーザID, パスワード)と、発行したリフレッシュトークンとその発行日時を保存します。パスワードとリフレッシュトークンはハッシュ化して保存します。
CREATE TABLE IF NOT EXISTS user (
userid VARCHAR(32) PRIMARY KEY,
password VARCHAR(64) NOT NULL,
refreshtoken VARCHAR(64),
refreshtoken_iat TIMESTAMP
);
Userエンティティ&リポジトリ
データベースのUserテーブルにJPAを使ってアクセスするためのオブジェクト&インタフェースです。
@Table(name = "user")
@Entity
@Getter
@Setter
public class User {
@Id
@Column(name = "userid", length = 32, nullable = false)
private String userId;
@Column(name = "password", length = 64, nullable = false)
private String password;
@Column(name = "refreshtoken", length = 64)
private String refreshToken;
@Column(name = "refreshtoken_iat")
private Instant refreshTokenIssuedAt;
}
public interface UserRepository extends JpaRepository<User, String> {
Optional<User> findByUserId(String userId);
}
Controller
/api/refreshtokenエンドポイントの実装場所です。アクセストークンが有効(期限切れはOK)、かつデータベースのリフレッシュトークンとハッシュ値が一致すれば、新しいトークンを発行します。
@RestController
@RequestMapping("/api")
public class Controller {
@Value("${jwt.accesstoken.secretkey}")
private String accessTokenSecret;
private JwtUserDetailsService userDetailsService;
public Controller(JwtUserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@PostMapping("/refreshToken")
public UserIssueToken refreshToken(@RequestBody UserIssueToken token) {
final DecodedJWT jwt = JWT.decode(token.getAccessToken());
try {
JWT.require(Algorithm.HMAC512(accessTokenSecret.getBytes())).build().verify(jwt);
} catch (TokenExpiredException e) {
// allow expired access token for user authentication
} catch (Exception e) {
throw e;
}
if (userDetailsService.verifyRefreshToken(jwt.getSubject(), token.getRefreshToken())) {
return userDetailsService.issueToken(jwt.getSubject());
} else {
throw new BadCredentialsException("Invalid refresh token!");
}
}
}
SecurityConfig
パブリックな/api/refreshToken宛て以外のリクエストはすべてフィルタを通し、認証・認可させるようにします。
ただデバッグ用にH2のウェブコンソールが使いたかったので、Same Originポリシー(コンソールにインラインフレームが使われてる)を付けて、/h2-console宛てのリクエストはフィルタを通らないようにしました。
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private JwtUserDetailsService userDetailsService;
@Value("${jwt.accesstoken.secretkey}")
private String accessTokenSecret;
public SecurityConfig(JwtUserDetailsService userService) {
this.userDetailsService = userService;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and().csrf().disable();
http
.authorizeRequests()
.antMatchers("/api/refreshToken", "/h2-console/**").permitAll()
.anyRequest().authenticated();
http
.addFilter(new JwtAuthenticationFilter(authenticationManager(), userDetailsService))
.addFilter(new JwtAuthorizationFilter(authenticationManager(), accessTokenSecret))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
JwtAuthenticationFilter
ユーザIDとパスワードを検証し、正しければアクセストークンとリフレッシュトークンを発行するフィルタです。
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
private JwtUserDetailsService userDetailsService;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtUserDetailsService userDetailsService) {
this.authenticationManager = authenticationManager;
this.userDetailsService = userDetailsService;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) throws AuthenticationException {
try {
final UserAuthentification user = new ObjectMapper().readValue(req.getInputStream(), UserAuthentification.class);
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
user.getUserId(),
user.getPassword(),
Collections.emptyList())
);
} catch (IOException e) {
throw new BadCredentialsException("Invalid credentials", e);
}
}
@Override
protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, FilterChain chain, Authentication auth) throws IOException {
final UserIssueToken issueToken = userDetailsService.issueToken(auth.getName());
final String json = (new ObjectMapper()).writeValueAsString(issueToken);
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
res.getWriter().write(json);
res.getWriter().flush();
}
}
JwtAuthorizationFilter
ユーザがAuthorizationリクエストヘッダに入れたアクセストークンを検証するフィルタです。
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private static final String TOKEN_PREFIX = "Bearer ";
private String accessTokenSecretKey;
public JwtAuthorizationFilter(AuthenticationManager authManager, String accessTokenSecretKey) {
super(authManager);
this.accessTokenSecretKey = accessTokenSecretKey;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
final String header = req.getHeader(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith(TOKEN_PREFIX)) {
chain.doFilter(req, res);
return;
}
final UsernamePasswordAuthenticationToken authentication = getAuthentication(req);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(req, res);
}
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
final String token = request.getHeader(HttpHeaders.AUTHORIZATION);
if (token != null) {
final String user = JWT.require(Algorithm.HMAC512(accessTokenSecretKey.getBytes()))
.build()
.verify(StringUtils.substringAfter(token, TOKEN_PREFIX))
.getSubject();
if (user != null) {
return new UsernamePasswordAuthenticationToken(user, null, new ArrayList<>());
}
}
return null;
}
}
JwtUserDetailsService
ユーザIDからユーザの検索や、アクセストークン&リフレッシュトークンの生成、リフレッシュトークンの検証を行うサービスです。
アクセストークンには、現時刻に一定の秒数を足した時刻を有効期限として書き込みます。
リフレッシュトークンの生成にはApache Commons LangのRandomStringUtils.randomAlphanumeric()
を使ってみました。UUIDでいいんじゃないかと思ったんですが、パスワード生成のような目的には適さないそうです。
リフレッシュトークンを検証する際、データベースに保存されている発行日時から、設定した秒数以上経っていないことを確認します。
@Service
public class JwtUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Value("${jwt.accesstoken.expirationtime}")
private long accessTokenExpTime;
@Value("${jwt.refreshtoken.expirationtime}")
private long refreshTokenExpTime;
@Value("${jwt.accesstoken.secretkey}")
private String accessTokenSecret;
private static final int REFRESH_TOKEN_LENGTH = 24;
private final Logger logger = LoggerFactory.getLogger(this.getClass());
public JwtUserDetailsService(UserRepository userRepository, @Lazy PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional(readOnly = true)
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
final com.github.otoiku.jwt_with_refreshtoken.model.User user = findByUsername(username);
return User.withUsername(user.getUserId())
.password(user.getPassword())
.authorities(Collections.emptyList())
.build();
}
@Transactional
public UserIssueToken issueToken(String username) throws UsernameNotFoundException {
final Instant now = Instant.now();
final String token = JWT.create()
.withSubject(username)
.withIssuedAt(Date.from(now))
.withExpiresAt(Date.from(now.plus(accessTokenExpTime, ChronoUnit.SECONDS)))
.sign(Algorithm.HMAC512(accessTokenSecret.getBytes()));
return UserIssueToken.builder()
.accessToken(token)
.refreshToken(generateRefreshToken(username))
.build();
}
@Transactional(readOnly = true)
public boolean verifyRefreshToken(String username, String refreshToken) throws UsernameNotFoundException {
final com.github.otoiku.jwt_with_refreshtoken.model.User user = findByUsername(username);
if(user.getRefreshTokenIssuedAt() != null &&
user.getRefreshTokenIssuedAt().plus(refreshTokenExpTime, ChronoUnit.SECONDS).isBefore(Instant.now())) {
logger.info("Refresh token of {} is already expired", username);
return false;
}
return StringUtils.isNotEmpty(user.getRefreshToken()) && passwordEncoder.matches(refreshToken, user.getRefreshToken());
}
private String generateRefreshToken(String username) throws UsernameNotFoundException {
final com.github.otoiku.jwt_with_refreshtoken.model.User user = findByUsername(username);
final String token = RandomStringUtils.randomAlphanumeric(REFRESH_TOKEN_LENGTH);
user.setRefreshToken(passwordEncoder.encode(token));
user.setRefreshTokenIssuedAt(Instant.now());
userRepository.save(user);
return token;
}
private com.github.otoiku.jwt_with_refreshtoken.model.User findByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUserId(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found:[" + username + "]"));
}
}