Featured image of post JWTによるREST APIのログインを実現する

JWTによるREST APIのログインを実現する

最近は、本格的にSpring SecurityによるREST APIでのログインの実装を勉強しています。実際の業務で使うことになるかどうかは分かりませんが、とりあえずREST APIで一般的な認証認可はどのように実装すべきで、Spring Securityはどう動くものかをまず理解しておかないとという気がして(そして最近はあまり実装していないので、感覚を失いたくないという願望もあり)、とりあえず自分で調べたことと、実装したコードの記録を兼ねて書きたいと思います。

他にもいろいろな方法があるのかもしれませんが、今回自分が選んだのはJWTとSpring Securityを使ったREST APIでの認証認可のところです。初めはSpring SecurityもJWTもREST APIでの認証認可も全く知識がなかったのでかなり苦労しましたが、とりあえず成功したので、わかったことをまとめてみました。

JWTとは

JWT(JSON Web Token)は、署名や暗号化などを含む情報をJSONオブジェクトとして伝達することができる、オープン標準RFC 7519です。JWTのデータは署名されているためこの伝達されるデータの送信元の特定や途中でデータが入れ替えされなかったか(改ざんされてないか)の検証などができるので、多くの場合にユーザの認証で使われます。

このようにJWTの中には署名や暗号化などに必要なデータは全て含まれているので、サーバサイドではJWTを受け取ることでデータのバリデーションチェックができるなど自己完結性があって、既存のSessionを使った方法とは別にStatelessな方法としてユーザの認証と認可を可能にします。なので今回紹介する、REST APIでのログインのために適している認証方式といえますね。

JWTでのログインシナリオ

まずJWTを使ったログインの場合、クライアントからログインに必要なクレデンシャル(IDやパスワードなど)をサーバに送ると、サーバからはユーザの情報に基づいてJWTを発行して返します。クライアントはこの情報をリクエスト毎に載せてサーバに伝達し、サーバではJWTのバリデーションを行なってからレスポンスを返すようになります。

ここでJWTとして作られたJWTをクライアント側に送る時やクライアントがリクエストを送る時、レスポンスやリクエストのHTTP Headerに載せることになります。なぜSessionではなくHeaderに載せるかについてはこのポストを参考にしてください。

ログインに成功したあとは、Sessionを使う場合と同じく認証させているかをチェックして各URLへのアクセスを認可するようになりますね。ここはSpring Securityが担当するようになります。

JWTとSpring Securityによるログイン

では、以上のシナリオをどう実現できるかを考えてみます。まず、ログイン要請を受けるコントローラとメソッドを用意する必要がありますね。そして、そのコントローラからはサービスクラスから入力されたクレデンシャルを検証してもらいます。検証できたら、その情報を元にJWTのJWTを作る必要がありますね。

Spring Securityの設定(JWTを使う前)

まずはREST APIでの認証認可を簡単にSpring Securityで実装します。Spring Securityそのものだけでもかなり膨大な量を勉強しなければならないのですが、ここではまず、DBに登録されているユーザの情報を取得し、そのユーザのロールによってアクセスできるURLを制限するという機能だけを実現します。(他にも、ログインとはあまり関係のないクラスのコードは省略しています)

Entityクラス

まずはDBからユーザの情報を取得するためのクラスを作る必要がありますね。UserDetailsをimplementしたクラスを簡単に作っておきます。これは既存にユーザのエンティティとして作っても良いですが、認証のための専用のクラスとなるので、別クラスとして作成しても構わないです。ただ、その場合はちゃんとテーブルで既存のユーザ情報と紐づくように管理する必要がありますね。

以下のコードは、UserDetailsクラスをSpring Data JPA基準のエンティティとして作成した例です。ここからユーザ名(username)とロール(roles)をJWTに載せて認証と認可に使うことにします。

@Data
@Entity
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    // ユーザ名(一般的にID)
    private String username;
    
    // ユーザアカウントが満了されているかの設定    
    private boolean accountNonExpired;

    // ユーザアカウントがロックされているかの設定
    private boolean accountNonLocked;

    // ユーザアカウントのクレデンシャルが満了されているかの設定
    private boolean credentialsNonExpired;

    // ユーザアカウントが活性化されているかの設定
    private boolean enabled;

    // 認可のためのユーザのロール
    @ElementCollection(fetch = FetchType.EAGER)
    private List<String> roles = new ArrayList<>();

    // ユーザの認可情報を取得する
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream()
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toUnmodifiableList());
    }
}

Serviceクラス

UserDetailsを取得するためのServiceクラスを作っておきます。このクラスでログイン後のユーザ情報を取得するようになります。UserDetailsServiceのメソッドはユーザ名からUserDetailsを取得するためのloadUserByUsernameしかないので、これを適切にRepositoryから取得できるように実装します。

@Service
public class UserServiceImpl implements UserDetailsService {

    // UserDetailsを取得できるRepository
    private final UserRepository repository;

    @Autowired
    public UserServiceImpl(UserRepository repository) {
        this.repository = repository;
    }
    
    // 認証後にユーザ情報を取得するためのメソッド
    @Override
    public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException {
        return this.repository.findByUsername(username);
    }
}

Configurationクラス

認可のためのConfigurationクラスです。ここではUSERというロールが設定されてない場合、どのURLにもアクセスできないようにしておきました。ログイン後、ユーザがURLにアクセスためのリクエストを送ると、この設定によりユーザのロールを確認してアクセスを認可するようになります。このロールはユーザを作成するとき、createUserなどのメソッドでrolesをROLE_USERとして保存するようにしておくと良いです。

また、今回作るのはREST APIであり、JWTによる認証と認可を行うことになるので、いくつかのデフォルト設定を変えておきます。例えばBasic AuthCSRFの設定や、セッション設定などがあります。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(final HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // Basic認証を使わない
                .httpBasic().disable()
                // CSRF設定を使わない
                .csrf().disable()
                // セッションはStatelessなので使わない
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // USERではないとどのURLでもアクセスできない
                .authorizeRequests()
                .anyRequest().hasRole("USER");
    }
}

これでSpring Securityを使うための必要最低限の準備は終わりました。他にユーザのCRUDのためのServiceやRepositoryは適宜作成してあるという前提として、次に進めましょう。

JWTの依存関係の追加

次に、本格的にJWTを使うための設定を行います。Spring BootでJWTを使うためには依存関係の追加が必要です。JWT自体は仕様が決まっていて、規格に合わせて適切なJSONとして作成した後にBase64としてエンコードしても実現はできますが、こういうものを扱う場合はなるべくライブラリを使った方が安全ですね。

Spring(Java)で使えるJWTのライブラリはいくつかありますが、JWT自体が標準なのでどちらを選んでも基本は同じです。ただ、ライブラリ毎にJWTの仕様にどこまで対応しているのかは違う場合があるので、公式サイトから対応しているライブラリのリストを確認してどれを使うかを選びましょう。このポストではJSON Web Token Support For The JVMを使った場合での実装方法を紹介します。

Mavenの場合は以下のように依存関係を追加します。

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

Gradleの場合の場合は以下となります。

dependencies {
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
}

JWTを提供するクラスを作る

依存関係を追加したら、ログイン成功時のResponseとクライアントからのリクエスト時のHeaderに載せるためのJWTを実際に作ってくれるクラスの作成が必要となります。色々な方法があると思いますが、ここではJWTを作成して検証もしてくれるようなクラスを作ります。その後はリクエスト毎に、HeaderのJWTを検証するためのクラスを作って行きます。

JWTの仕様

JWTを作る前に、まず簡単にJWTがどんな構造を持っているかを見ていきましょう。JWTはHeaderPayloadSignatureという三つの要素で構成されています。それぞれの要素はBase64の文字列としてエンコードして、つなげることで一つのJWTが構成されます。

Headerの構成

Headerではこのトークンがどのようなもので、どのアルゴリズムでエンコードされているかを表す情報を載せるようになります。

id意味詳細
typToken TypeJWTのタイプ(=JWT)
algHashing Algoritymエンコードする時のアルゴリズム
Payloadの構成

Payloadはnameとvalueのペアで構成された複数の情報単位で構成されている、いわばBodyのようなもので、ここに載せる個々の情報単位たちをClaimと呼びます。今回のログインではこのClaimにユーザのIDとロール、そしてトークンが発行された期間を載せることにします。

id意味詳細
jtiJWT IDJWTの識別子
subsubjectJWTのユニークなキー
ississuerJWTを発行者
audaudienceJWTの利用者
iatissued atJWTが発行された時間
nbfnot beforeJWTの開始時間
expexpiration timeJWTの満了時間
Sigunature

SignatureではHeaderとPayloadをエンコードしたあと、更に任意のシークレットキーを持ってエンコードします。これによって、サーバ側ではクライアントが送ってきたJWTをSignatureをデコードしてHeaderとPayloadを取得できるようになります。

Header、Payload、Signature順で正しく作成したJWTは、JWTの公式サイトから検証できます。以下のようにJWTの構造と格納しているデータを確認してデバッグができるので、興味のある方はぜひ試してみてください。

Structure of JWT

Token Providerクラスの実装

では、JWTがどんな情報により構成されているかがわかったので、必要な情報を載せて実際のJWTを作成するクラスを作りましょう。ここでHeader、Payload、Signatureの全部を埋める必要はなく、最低限の情報だけ使うことにします。

まずトークンを作成するメソッドを作ります。ここで作成されるJWTのPayloadに載せるClaimは、ユーザ名とロールに限定します。また、JWTが発行された時間と満了時間を設定して、期間外では使えないようにします。そして最後にカスタムシークレットキーを設定してSignatureを作成することにします。

他には、トークンを読み込んでDBのユーザ情報を取得するメソッド、リクエストのHeaderからトークンを取得するメソッド、トークンの有効期間を検証するメソッド、トークンにのせたユーザ名(ID)を取得するメソッドなども作っておきます。(こちらは別途クラスで切っても良さそうな気はします)

@Component
public class JWTProvider {

    // Signatureのエンコードに使うシークレットキー
    private static final String TOKEN_SECRET_KEY = "This is secrect!";

    // トークンの有効期間(1時間)
    private static final long TOKEN_VAILD_DURATION = 1000L * 60L * 60L;

    // ユーザ情報を取得するためのサービスクラス
    private final UserDetailsService service;

    @Autowired
    public JWTProvider(UserDetailService service) {
        this.service = service;
    }

    // UserオブジェクトからJWTを作成する
    public String createToken(User user) {
        // Claimとしてユーザ名とロールを載せる
        Claims claims = Jwts.claims().setSubject(user.getId());
        claims.put("roles", user.getRoles());
        // トークンの開始時間と満了時間を決める
        Date iat = new Date();
        Date exp = new Date(start.getTime() + TOKEN_VAILD_DURATION);
        // JWTの作成
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(iat)
                .setExpiration(exp)
                .signWith(SignatureAlgorithm.HS256, TOKEN_SECRET_KEY)
                .compact();
    }

    // トークンからユーザ情報を取得する
    public Authentication getAuthentication(final String token) {
        final UserDetails userDetails = this.service.loadUserByUsername(this.getSubject(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // リクエストのHeaderからトークンを取得する
    public String resolveToken(final HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

    // トークンの有効期間を検証する
    public boolean validateToken(final String token) {
        try {
            final Jws<Claims> claims = Jwts.parser().setSigningKey(this.secretKey).parseClaimsJws(token);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }

    // トークンからユーザ名を取得する
    pubic String getSubject(final String token) {
        return Jwts.parser().setSigningKey(this.secretKey).parseClaimsJws(token).getBody().getSubject();
    }
}

カスタムFilterクラスの実装

次に、ログインするときにリクエストを検証するためのFilterクラスを作成します。最初ユーザがログインすると、このクラスでは先に作成したJWTProviderを使って、Headerからトークンを取得、有効期間の検証を行った後、問題なければDBからユーザ情報を取得してSpring Securityの認証情報(Authentication)としてセットするようになります。

@Component
public class JWTAuthenticationFilter extends GenericFilterBean {

    // トークンを検証するためのProvider
    private final JWTProvider provider;

    @Autowired
    public JWTAuthenticationFilter(JWTProvider provider) {
        this.provider = provider;
    }

    // ログインに対するフィルタリングを行う
    @Override
    public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain filterChain)
            throws IOException, ServletException {
        final String token = this.provider.resolveToken((HttpServletRequest) request);
        if (token != null && this.provider.validateToken(token)) {
            final Authentication auth = this.provider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        filterChain.doFilter(request, response);
    }
}

Spring SecurityにJWTでのログイン設定を追加する

では、JWTを作成して検証するための準備が終わったので、これからはSpring Securityでこのクラスたちを使って認証と認可を行うための設定をする版ですね。どれが定番だとかという訳ではないですが、どちらも好みに合わせて(もしくは要件に合わせて)使えるのではないかと思い、三つの方法を用意しました。

FormLoginを使う場合

Spring SecurityのFormLoginを利用してログインのためのURLと、ログイン後のJWT作成を全て設定しておきたい場合の実装です。クライアント側ではログインのためのクレデンシャルをPOSTのFormデータとして送るようになります。

ここではログインを処理するためのURLとクレデンシャルのためのパラメータなどを設定して、ログインに成功したらトークンを作成して返すためのAuthenticationSuccessHandlerを設定することになります。

AuthenticationSuccessHandlerの作成

まずログインに成功した場合にトークンを返すためのSuccessHandlerを作成します。ログインに成功した場合、Authenticationクラスにユーザ情報が保存されるので、それをProviderに渡してトークンを作成してもらった後にレスポンスのHeaderに載せて返すことをやっています。

@Component
public class JWTAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    // トークンを作成するためのProvider
    final private JWTProvider provider;

    @Autowired
    public JWTAuthenticationSuccessHandler(JWTProvider provider) {
        this.provider = provider;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication auth) throws IOException, ServletException {
        // すでにレスポンスで情報を返した場合は何もしない
        if (response.isCommitted()) {
            return;
        }
        // ログインに成功したユーザ情報を取得する
        User user = (User) auth.getPrincipal();
        // Headerにトークンを作成して載せる
        response.setHeader("X-AUTH-TOKEN", this.provider.createToken(user));
        // HTTP Statusは200 OK
        response.setStatus(HttpStatus.OK.value());
    }
}
Spring Securityの設定

では、ログインに成功したときのHandlerクラスを作成したので、Spring Securityの設定を変えていきます。必要なのはloginProcessingUrl()(ログインの処理をするためのPOST通信が行われるURLとHanlderの設定で、ユーザ名のパラメータ名やパスワードのパラメタ名はデフォルトと違う場合にだけ必要となります。そしてFilterはデフォルト設定だとUsernamePasswordAuthenticationFilterが先に実行されるので、先ほど作成したJWTAuthenticationFilterを使うための設定をします。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // ログインが成功した場合の処理のためのHandler
    private final JWTAuthenticationSuccessHandler successHandler;

    // ログイン以降の認証認可のためのFilter
    private final JWTAuthenticationFilter filter;

    @Autowired
    public SecurityConfig(JWTAuthenticationSuccessHandler successHandler, JWTAuthenticationFilter filter) {
        this.successHandler = successHandler;
        this.filter = filter;
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(final HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // formLoginを使う
                .formLogin()
                // POSTでクレデンシャルをもらい、ログイン処理を行うURL(UserDetailsServiceを使うことになる)
                .loginProcessingUrl("/api/v1/web/login")
                // ログイン処理ようのURLには認証認可なしでアクセスできる
                .permitAll()
                // ユーザ名のパラメータ(デフォルトはusername)
                .usernameParameter("id")
                // ユーザパスワードのパラメータ(デフォルトはpassword)
                .passwordParameter("pass")
                // ログインに成功したら実行されるsuccessHandlerの指定
                .successHandler(this.successHandler)
                .and()
                .authorizeRequests()
                .anyRequest().hasRole("ROLE_USER")
                .and()
                // デフォルトのFilter設定を変える
                .addFilterBefore(this.filter, UsernamePasswordAuthenticationFilter.class);
    }
}

JSONを使えるFilterを使う場合

Spring SecurityのFormLoginを使う場合、ログインのためのクレデンシャルはFormデータである必要があります。しかし、REST APIならばデータのやりとりはJSONが基本ですね。なのでクレデンシャルもJSONで送るようにしたいのですが、Spring Securityではそのようなメソッドを提供していません。

こういう場合は、カスタムUsernamePasswordAuthenticationFilterクラスを作ってJSONをパースする必要があります。FormLoginを使う場合に比べて少し複雑になりますが、REST APIらしくやってみましょう。FromLoginでの実装ができると、こちらはFilterをもう一つ追加するだけの感覚で実装ができます。

JsonUsernamePasswordAuthenticationFilterの作成

クレデンシャルを確認するSpringのデフォルトのFilterクラスはUsernamePasswordAuthenticationFilterというもので、これを継承したカスタムクラスを作り、そこからJSONをパースして使うようにします。以下の実装は、FormデータとJSONの両方に対応している例です。

public class JsonUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    // Headerからコンテントタイプを取得するための定数
    private static final String CONTENT_TYPE = "Content-Type";
    
    // JSONデータを保存するためのMap
    private Map<String, String> jsonRequest;

    // ユーザ名を取得する
    @Override
    protected String obtainUsername(HttpServletRequest request) {
        return getParameter(request, getUsernameParameter());
    }

    // パスワードを取得する
    @Override
    protected String obtainPassword(HttpServletRequest request) {
        return getParameter(request, getPasswordParameter());
    }
    
    // JSONもしくはFormデータのクレデンシャルを取得し、Authenticationとして載せる
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (headerContentTypeIsJson(request)) {
            ObjectMapper mapper = new ObjectMapper();
            try {
                this.jsonRequest = mapper.readValue(request.getReader().lines().collect(Collectors.joining()),
                        new TypeReference<Map<String, String>>() {
                        });
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        String username = obtainUsername(request) != null ? obtainUsername(request) : "";
        String password = obtainPassword(request) != null ? obtainPassword(request) : "";
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    // リクエストからパラメータ(ユーザ名とパスワード)を取得する
    private String getParameter(HttpServletRequest request, String parameter) {
        if (headerContentTypeIsJson(request)) {
            return jsonRequest.get(parameter);
        } else {
            return request.getParameter(parameter);
        }
    }

    // HeaderからコンテントタイプがJSONかどうかを判定する
    private boolean headerContentTypeIsJson(HttpServletRequest request) {
        return request.getHeader(CONTENT_TYPE).equals(MediaType.APPLICATION_JSON_VALUE);
    }
}
AuthenticationSuccessHandlerの作成

ここはFormLoginの場合と同じです。詳細についてはこちらを参考にしてください。

Spring Securityの設定

ここでは、作成したカスタムUsernamePasswordAuthenticationFilterにFilterProcessUrlと、AuthenticationManager、AuthenticationSuccessHandlerを設定して、デフォルトのフィルタ設定を変えるようになります。この設定ではFormLoginが要らなくなリます。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // ログインが成功した場合の処理のためのHandler
    private final JWTAuthenticationSuccessHandler successHandler;

    // ログイン以降の認証認可のためのFilter
    private final JWTAuthenticationFilter filter;

    @Autowired
    public SecurityConfig(JWTAuthenticationSuccessHandler successHandler, JWTAuthenticationFilter filter) {
        this.successHandler = successHandler;
        this.filter = filter;
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(final HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // FormLoginは使わない
                .formLogin().disable()
                .authorizeRequests()
                .anyRequest().hasRole("ROLE_USER")
                .and()
                // 認証前にJWTのFilterを設定
                .addFilterBefore(this.filter, UsernamePasswordAuthenticationFilter.class)
                // UsernamePasswordAuthenticationFilterはカスタムクラスに代替
                .addFilterAt(getJsonUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    // カスタムUsernamePasswordAuthenticationFilterの設定
    private JsonUsernamePasswordAuthenticationFilter getJsonUsernamePasswordAuthenticationFilter() {
        JsonUsernamePasswordAuthenticationFilter jsonFilter = new JsonUsernamePasswordAuthenticationFilter();
        try {
            // ログインを処理するURLの設定
            jsonFilter.setFilterProcessesUrl("/api/v1/web/login");
            // AuthenticationManagerの設定
            jsonFilter.setAuthenticationManager(this.authenticationManagerBean());
            // AuthenticationSuccessHandlerの設定
            jsonFilter.setAuthenticationSuccessHandler(this.successHandler);
            jsonFilter.setUsernameParameter("id");
            jsonFilter.setPasswordParameter("pass");
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return jsonFilter;
    }
}

ログイン用のControllerを使う場合

他のURLをControllerで制御しているのと同じく、ログイン専用のContollerを作る場合の例です。一般的なControllerとあまり使い方は変わらないので、こちらの方がやりやすい感もしますね。

また、レスポンスとしてResponseEntityやカスタムクラスも使えるのでHeaderにトークンを載せるだけでなく、Bodyに何かデータを埋めて共に送る必要のある場合はこちらの方が良い選択なのかもしれません。

Controllerの作成

前述した通り、一般的なREST API用のControllerとあまり変わりないものを作ります。ログインようのURLと、それに紐づくメソッドを作り、ログイン時の認証を担当することになります。

@RestController
@RequestMapping("api/v1/web")
public class SignApiController {

    // クレデンシャルを検証するためのサービスクラス
    private final UserService service;
    
    // トークンを作成するためのProvider
    private final JWTProvider provider;

    @Autowired
    public SignApiController(MemberService service, JWTProvider provider) {
        this.service = service;
        this.provider = provider;
    }

    // Formデータでクレデンシャルをもらい、認証を行う
    @PostMapping("/login")
    public void login(@Validated @RequestBody LoginMemberForm form, HttpServletResponse response) {
        // クレデンシャルからユーザ情報を取得
        User user = this.service.getUser(form.getId(), form.getPassword());
        // 取得した情報でトークンを作成
        String token = this.provider.createToken(user);
        // Headerにトークンを作成して載せる
        response.setHeader("X-AUTH-TOKEN", this.provider.createToken(user));
        // HTTP Statusは200 OK
        response.setStatus(HttpStatus.OK.value());
    }
}
Spring Securityの設定

Controllerを使った場合は、認可認可なしでもログイン用のURLにアクセスできる設定と、Filterを使うための設定を追加します。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // ログイン以降の認証認可のためのFilter
    private final JWTAuthenticationFilter filter;

    @Autowired
    public SecurityConfig(JWTAuthenticationFilter filter) {
        this.provider = provider;
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(final HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // ログイン処理ようのURLには認証認可なしでアクセスできる
                .antMatchers("/api/v1/web/login/").permitAll()
                .anyRequest().hasRole("ROLE_USER")
                .and()
                // デフォルトのFilter設定を変える
                .addFilterBefore(this.filter, UsernamePasswordAuthenticationFilter.class);
    }
}

テスト

今まで実装したログインのテストは、CURLもしくはPostmanなどのツールで簡単にできます。

例えば、FormLoginを使った場合のログインはこちらになります。

curl -i -X POST "http://localhost:8080/api/v1/web/login" -d "id=user" -d "pass=1234"

Postmanを使ったJSONでのログインテストはこちらになります。(X-AUTH-TOKENでJWTが帰ってきたのを確認できます!)

JWT Postman Login

最後に

思ったよりSpring Security周りの設定がいろいろと必要となり、自分の欲しがっていたレクチャはあまりなかったのでかなり苦労しましたが、これでなんとかREST APIでのJWTを使ったログインは実装できました。これだけを別途ライブラリとして作っても良いかと思いますね…

でも、まだこれで完全な設定ができた訳ではありません。ここではSuccessHandlerのみを作成しましたが、場合によってはログインに失敗した場合のAuthenticationFailureHandlerが必要になる可能性もあります。また、これはあくまでログインに関するポストなので扱ってはなかったのですが、認証認可できてないURLへのアクセスに対するException Handlingも必要です。また、JWTを使った認証の場合、クライアントがトークンを持ってしまうのでサーバ側からログアウトを制御できないという点があり、クライアント側の実装ではそこに対しての対策も考えなければなりません。

が、今回はとりあえず最小限の目標は達成できたということで、いったんここまでとなります。では、また!

Built with Hugo
Theme Stack designed by Jimmy