Help us understand the problem. What is going on with this article?

JWTを利用したSpringアプリのAPI認証

More than 3 years have passed since last update.

APIサーバとして作っているSpringBootアプリに認証の仕組みを入れようと思い、試行錯誤した内容をまとめておきます。

spring-securityを入れればよろしくやってくれるんじゃないの?と軽く思っていたのですが、思ったより長い道のりになってしまいました。

ID/PWでtokenを発行して、tokenを元にその他のAPIを呼び出せるか制御したい というのがやりたいことです。
いろいろ調べるとJWTという技術が使えそうだと判明して、「Implementing JWT Authentication on Spring Boot APIs」というサイトを参考に実装しています。

JWT(Json Web Token)とは

以下の3つのJSONデータを.で結合してできた文字列を、URLでも安全に送れるように暗号化したものです。これをtokenとして利用します。ジョットと読むらしいです

  • Header 使用しているアルゴリズムをbase64エンコードして設定 { "alg": "HS512"}
  • Payload データ。今回のサンプルではユーザ名と有効期限がbase64エンコードして設定されている { "sub": "nyasba", "exp": 1516271467 }
  • Signature サーバ側のsecretを元に作成した署名

token自体はサーバ側でステートを管理しているわけではないというのが特徴です。

詳細な仕組みを知りたい方はこのあたりも参照してください。

demo

まずはイメージをつかむために出来上がったもののデモです

token発行

レスポンスヘッダで返していますが、bodyで返すことも可能です

curl -v -X POST -d "{ \"loginId\" : \"nyasba\", \"pass\" : \"password\"}" -H "accept: application/json" -H "Content-Type: application/json" "http://localhost:8080/user/login"

< HTTP/1.1 200
< Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJueWFzYmEiLCJleHAiOjE1MTYyNzE0Njd9.LXCxgcrDW-gtwSnAus3nVAMBrhQitAxmDn4k7dMZ9u82PLqnw467xjpwk67oz93PNy80cNxfBg0LuVbehIih3w

tokenを利用したAPI呼び出し

リクエストヘッダでtokenを渡しています。

curl -X GET -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJueWFzYmEiLCJleHAiOjE1MTYyNzE0Njd9.LXCxgcrDW-gtwSnAus3nVAMBrhQitAxmDn4k7dMZ9u82PLqnw467xjpwk67oz93PNy80cNxfBg0LuVbehIih3w" "http://localhost:8080/private" 
this is private for nyasba

もちろんヘッダを指定しないと拒否されます

curl -X GET "http://localhost:8080/private"
{"timestamp":1516242884788,"status":403,"error":"Forbidden","message":"Access Denied","path":"/private"}

作ったもの

全てソースはこちらに入っています。実際に実装する方はgithubの方を見ていただいた方がよいかと思います。
https://github.com/nyasba/spring-jwt-auth

build.gradle

Spring Securityに加えてjjwtを追加しています

    compile('org.springframework.boot:spring-boot-starter-web')
    compile('org.springframework.boot:spring-boot-starter-security')
    compile('io.jsonwebtoken:jjwt:0.9.0')

Controller

/privateを認証が必要なAPIと想定しています。後ほど説明しますが、ログイン用のパスは作りません。

SampleController
@RestController
public class SampleController {

    @GetMapping(value = "/public")
    public String publicApi() {
        return "this is public";
    }

    @GetMapping(value = "/private")
    public String privateApi() {

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        // JWTAuthenticationFilter#successfulAuthenticationで設定したusernameを取り出す
        String username = (String) (authentication.getPrincipal());

        return "this is private for " + username;
    }
}

WebSecurityConfig

WebSecurityConfigurerAdapter を拡張したConfigクラスで/publicやログイン画面などは認証不要とします。
全体の構成は理解するためにConfigの中で出てくるクラスの役割もまとめておきます。

クラス名 役割
JWTAuthenticationFilter 認証フィルター。login用のpathでアクセスされた際にID/PWを取り出してトークンを発行してresponseヘッダにセットする
JWTAuthorizationFilter 認可フィルター。ヘッダにあるtokenを取り出し、tokenを検証する
UserDetailsService ログインユーザを検索する
BCryptPasswordEncoder パスワードを暗号化する
WebSecurityConfig
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .cors()
                .and().authorizeRequests()
                .antMatchers("/public", SIGNUP_URL, LOGIN_URL).permitAll()
                .anyRequest().authenticated()
                .and().logout()
                .and().csrf().disable()
                .addFilter(new JWTAuthenticationFilter(authenticationManager(), bCryptPasswordEncoder()))
                .addFilter(new JWTAuthorizationFilter(authenticationManager()))
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        ;
    }


    @Autowired
    public void configureAuth(AuthenticationManagerBuilder auth) throws Exception {
        auth
                .userDetailsService(userDetailsService)
                .passwordEncoder(bCryptPasswordEncoder());
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

JWTAuthenticationFilter (認証フィルター)

UsernamePasswordAuthenticationFilter のソースを読めばすぐわかりますが、デフォルトでは/loginに対してPOSTメソッドでusername,passwordというキーワードでログイン認証を行う仕様になっています。それをカスタマイズしています。

/loginでアクセスされた場合はこのフィルターで処理されるため、Controllerは用意しても動きません。ここは挙動を理解するのがすごく難しかったところでした。

JWTAuthenticationFilter
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private static final Logger LOGGER = LoggerFactory.getLogger(JWTAuthenticationFilter.class);

    private AuthenticationManager authenticationManager;
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    public JWTAuthenticationFilter(AuthenticationManager authenticationManager, BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.authenticationManager = authenticationManager;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;

        // ログイン用のpathを変更する
        setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(LOGIN_URL, "POST"));

        // ログイン用のID/PWのパラメータ名を変更する
        setUsernameParameter(LOGIN_ID);
        setPasswordParameter(PASSWORD);

    }

    // 認証の処理
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req,
                                                HttpServletResponse res) throws AuthenticationException {
        try {
            // requestパラメータからユーザ情報を読み取る
            UserForm userForm = new ObjectMapper().readValue(req.getInputStream(), UserForm.class);

            return authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(
                            userForm.getLoginId(),
                            userForm.getPass(),
                            new ArrayList<>())
            );
        } catch (IOException e) {
            LOGGER.error(e.getMessage());
            throw new RuntimeException(e);
        }
    }


    // 認証に成功した場合の処理
    @Override
    protected void successfulAuthentication(HttpServletRequest req,
                                            HttpServletResponse res,
                                            FilterChain chain,
                                            Authentication auth) throws IOException, ServletException {
        // loginIdからtokenを設定してヘッダにセットする
        String token = Jwts.builder()
                .setSubject(((User)auth.getPrincipal()).getUsername()) // usernameだけを設定する
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .signWith(SignatureAlgorithm.HS512, SECRET.getBytes())
                .compact();
        res.addHeader(HEADER_STRING, TOKEN_PREFIX + token);

        // ここでレスポンスを組み立てると個別のパラメータを返せるがFilterの責務の範囲内で実施しなければならない
        // auth.getPrincipal()で取得できるUserDetailsは自分で作ったEntityクラスにもできるのでカスタム属性は追加可能
    }

}

JWTAuthorizationFilter (認可フィルター)

リクエストにAuthorizationヘッダがあった場合はtokenを検証する

JWTAuthorizationFilter
public class JWTAuthorizationFilter extends BasicAuthenticationFilter {
    private AuthenticationManager authenticationManager;

    public JWTAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException {
        String header = req.getHeader(HEADER_STRING);

        if (header == null || !header.startsWith(TOKEN_PREFIX)) {
            chain.doFilter(req, res);
            return;
        }

        // AuthorizationヘッダのBearer Prefixである場合
        UsernamePasswordAuthenticationToken authentication = getAuthentication(req);

        SecurityContextHolder.getContext().setAuthentication(authentication);
        chain.doFilter(req, res);
    }

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        String token = request.getHeader(HEADER_STRING);
        if (token != null) {
            // parse the token.
            String user = Jwts.parser()
                    .setSigningKey(SECRET.getBytes())
                    .parseClaimsJws(token.replace(TOKEN_PREFIX, ""))
                    .getBody()
                    .getSubject();

            if (user != null) {
                return new UsernamePasswordAuthenticationToken(user, null, new ArrayList<>());
            }
            return null;
        }
        return null;
    }
}

UserDetailsService

ここはダミーです。本来はDB参照などを実施するかと思います。
どこで設定するパスワードが暗号化されているべきなのかが理解しづらかったので、その参考にしていただければ。

UserDetailsServiceImpl
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private static List<String> usernameList = Arrays.asList("nyasba", "admin");
    private static String ENCRYPTED_PASSWORD = "$2a$10$5DF/j5hHnbeHyh85/0Bdzu1HV1KyJKZRt2GhpsfzQ8387A/9duSuq"; // "password"を暗号化した値

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        // 本来ならここでDBなどからユーザを検索することになるが、サンプルのためリストに含まれるかで判定している
        if(!usernameList.contains(username)){
            throw new UsernameNotFoundException(username);
        }

        return User.withUsername(username)
                .password(ENCRYPTED_PASSWORD)
                .authorities("ROLE_USER") // ユーザの権限
                .build();
    }

}

所感

spring securityが全部やってくれると思ってたところから考えると大変でしたが、中身が理解できるとシンプルに思えます。参考にした記事を読み込んでいるときはどこがどうなっているんだと混乱していたのですが不思議ですw

Passwordという中で生のパスワードと暗号化されたパスワードが混ざっている、getPrincipalで取れるオブジェクトがUserだったりusernameのStringだったりするのがわかりにくい、などが課題かなと思います。

nyasba
IT Architect / VP of Engineering @GxP
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away