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

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

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

**ID/PWでtokenを発行して、tokenを元にその他のAPIを呼び出せるか制御したい** というのがやりたいことです。
いろいろ調べるとJWTという技術が使えそうだと判明して、「[Implementing JWT Authentication on Spring Boot APIs](https://auth0.com/blog/implementing-jwt-authentication-on-spring-boot/)」というサイトを参考に実装しています。


## JWT(Json Web Token)とは

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

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

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


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

* JWT(JSON Web Token)を使った認証を試みる https://blog.kazu69.net/2016/07/30/authenticate_with_json_web_token/
* JWTについて簡単にまとめてみた https://hiyosi.tumblr.com/post/70073770678/
* RFC7519　https://tools.ietf.org/html/rfc7519


## 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と想定しています。後ほど説明しますが、ログイン用のパスは作りません。

```java: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 | パスワードを暗号化する | 


```java: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は用意しても動きません。ここは挙動を理解するのがすごく難しかったところでした。

```java: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を検証する

```java: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参照などを実施するかと思います。
どこで設定するパスワードが暗号化されているべきなのかが理解しづらかったので、その参考にしていただければ。

```java: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が全部やってくれると思ってたところから考えると大変でしたが、中身が理解できるとシンプルに思えます。参考にした記事を読み込んでいるときはどこがどうなっているんだと混乱していたのですが不思議ですｗ

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