36
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

SpringでJWT認証をリフレッシュトークン込みで実装してみた

Last updated at Posted at 2021-01-30

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/ でデコードして中身を確認できます。

認証の流れはこんな感じになります。(この後出てくるリフレッシュトークンと紛らわしいので、これからアクセストークンと呼ぶことにします。)

  1. クライアントはユーザIDとパスワードをサーバに送る。
  2. サーバが認証を行い、OKだったらアクセストークンを発行しクライアントに送る。
  3. クライアントはリクエストのたびにアクセストークンも一緒に送る。
  4. サーバがアクセストークンを復号して認証・認可する。

セキュリティの観点からアクセストークンの有効期限は短めに設定します(例えば20分)。ただし、これだとユーザは頻繁に認証しなおさなければなくなるため不便です。そのため、アクセストークンとは別にリフレッシュトークンというものも発行し、アクセストークンの有効期限が切れてもリフレッシュトークンで新しいアクセストークンを取得しなおせるようにします。リフレッシュトークンの有効期限はアクセストークンよりも長く設定します(数週間とか数か月とか)。

従って、リフレッシュトークンを含めたプロセスはこんな感じになります。

  1. クライアントはユーザIDとパスワードをサーバに送る。
  2. サーバが認証を行い、OKだったらアクセストークン***&リフレッシュトークン***を発行しクライアントに送る。
  3. クライアントはリクエストのたびにアクセストークンも一緒に送る。
  4. サーバがアクセストークンを復号して認証・認可する。
  5. アクセストークンの有効期限が切れサーバがエラーを返した場合:クライアントはリフレッシュトークンをサーバに送ってアクセストークンの再発行を行う。

ステップ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のインメモリデータベースを使用
build.gradle(一部抜粋)
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, パスワード)と、発行したリフレッシュトークンとその発行日時を保存します。パスワードとリフレッシュトークンはハッシュ化して保存します。

schema.sql
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を使ってアクセスするためのオブジェクト&インタフェースです。

User.java
@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;
}
UserRepository.java
public interface UserRepository extends JpaRepository<User, String> {
    Optional<User> findByUserId(String userId);
}

Controller

/api/refreshtokenエンドポイントの実装場所です。アクセストークンが有効(期限切れはOK)、かつデータベースのリフレッシュトークンとハッシュ値が一致すれば、新しいトークンを発行します。

Controller.java
@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宛てのリクエストはフィルタを通らないようにしました。

SecurityConfig.java
@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とパスワードを検証し、正しければアクセストークンとリフレッシュトークンを発行するフィルタです。

JwtAuthenticationFilter.java
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リクエストヘッダに入れたアクセストークンを検証するフィルタです。

JwtAuthorizationFilter.java
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でいいんじゃないかと思ったんですが、パスワード生成のような目的には適さないそうです。

リフレッシュトークンを検証する際、データベースに保存されている発行日時から、設定した秒数以上経っていないことを確認します。

JwtUserDetailsService.java
@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 + "]"));
    }
}
36
25
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
36
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?