Angular7とSpring Bootでログイン有効時間30分のJWT認証してみた

# 概要
SPAとSpring Securityでログインの仕組みを作りたくてやってみました。

JWTについていろいろ調べて真似をすると動くものが作れましたが、一度ログインしてからずっと同じtokenを使うような方式だったので、ログイン後何かしら操作してもtokenの有効期限が更新されず、ログイン時に設定された有効期限を経過するとログアウトしてしまっていました。
そこで、よくある「最後に操作してから30分はログインが維持される」という方式のものを作りました。
具体的には、アクセスがあるたびにtokenを発行しなおすという手法をとりました。
もっといい方法をご存知の方がいらっしゃいましたらコメント欄にご意見いただけますと幸いです。

### 環境
- Spring Boot 2.1.1.RELEASE
- Spring Security 
- jjwt 0.9.0
- Lombok
- modelmapper
- jpa
- Angular7

### こちらを参考にさせていただきました

- [JWTを利用したSpringアプリのAPI認証](https://qiita.com/nyasba/items/f9b1b6be5540743f8bac)

今回の投稿は、こちらの記事に掲載されているものを少しアレンジさせていただきました。
また、併せてangular側のコードを載せます。

- [JWT について調べた内容をまとめました。](https://qiita.com/t-toyota/items/8d0ffb265845ab8cc87c)

JWTの仕組みついてはこちらを参考にさせていただきました。

# 実装（Spring Boot側）
### SecurityConfig
- angularと連携するためにCORSを有効化
- JWTを使用するにあたって自作フィルターを設定
- JWTはhttpヘッダーにつけてやり取りする

あとはよくあるSpring Securityの設定です。

```SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .cors()
                .configurationSource(this.corsConfigurationSource())
                .and().authorizeRequests()
                .anyRequest().authenticated()
                .and().logout()
                .and().csrf().disable()
                // ここでjwt用の自作フィルターを2つ登録（詳細は後述）
                .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();
    }

    // Angularを起動しているlocalhost:4200からのCORSを有効化
    private CorsConfigurationSource corsConfigurationSource(){
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedMethod(CorsConfiguration.ALL);
        corsConfiguration.addAllowedHeader(CorsConfiguration.ALL);
        // jwt用のhttpヘッダーを登録
        corsConfiguration.addExposedHeader(CommonConstant.AUTHORIZATION_HEADER_NAME);
        corsConfiguration.addAllowedOrigin("http://localhost:4200");
        corsConfiguration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource corsSource = new UrlBasedCorsConfigurationSource();
        corsSource.registerCorsConfiguration("/**", corsConfiguration);

        return corsSource;
    }
}
```
### TokenCreater（トークン発行クラス）
自作フィルター2つのいずれにおいてもトークンを発行するので、トークン発行クラスを設定。

``` TokenCreater.java
public class TokenCreater {
    public static String createToken( String mailAddress){
        return Jwts.builder()
                .setSubject(mailAddress) 
                // 有効期限30分（ミリ秒で指定）
                .setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000))
                .signWith(SignatureAlgorithm.HS512, CommonConstant.CRYPT_KEY.getBytes())
                .compact();
    }
}
```
### JWTAuthenticationFilter(認証フィルター)

- `/authenticate`にPOSTされたときにここを通る。
- 認証情報に問題がなければJWTを発行し、レスポンスヘッダーに付ける。

```JWTAuthenticationFilter.java
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private AuthenticationManager authenticationManager;
    private BCryptPasswordEncoder bCryptPasswordEncoder;

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

        // ログイン用のpath
        setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/authenticate", "POST"));

        // ログイン用のID/PWのパラメータ名
        setUsernameParameter("loginId");
        setPasswordParameter("password");

    }

    // 認証の処理
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req,
                                                HttpServletResponse res) throws AuthenticationException {
        try {
            UserForm userForm = new ObjectMapper().readValue(req.getInputStream(), UserForm.class);

            // 認証情報をセット。
            return authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(
                            userForm.getLoginId(),
                            userForm.getPassword(),
                            new ArrayList<>())
            );
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }


    // 認証に成功した場合の処理
    @Override
    protected void successfulAuthentication(HttpServletRequest req,
                                            HttpServletResponse res,
                                            FilterChain chain,
                                            Authentication auth) throws IOException, ServletException {
        // loginIdからtokenを設定してヘッダにセットする
        String token = TokenCreater.createToken(((LoginUserDemo)auth.getPrincipal()).getUsername());
        res.addHeader(CommonConstant.AUTHORIZATION_HEADER_NAME, CommonConstant.JWT_PREFIX + token);
    }

}
```
### JWTAuthorizationFilter (認可フィルター)
ログイン後、Spring Securityで守られたパスにアクセスされたときにとおる。

- JWTを検証
- JWTの有効期限を延長（問題なければJWTの再発行）

```JWTAuthorizationFilter.java
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(CommonConstant.AUTHORIZATION_HEADER_NAME);

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

        UsernamePasswordAuthenticationToken authentication = getAuthentication(req);

        // jwtの期限を更新
        // これにより、ログイン後アクセスするたびにjwtの期限が更新される
        String token = TokenCreater.createToken(authentication.getPrincipal().toString());
        res.addHeader(CommonConstant.AUTHORIZATION_HEADER_NAME,
                CommonConstant.JWT_PREFIX + token);

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

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        String token = request.getHeader(CommonConstant.AUTHORIZATION_HEADER_NAME);
        if (token != null) {
            String user = Jwts.parser()
                    .setSigningKey(CommonConstant.CRYPT_KEY.getBytes())
                    .parseClaimsJws(token.replace(CommonConstant.JWT_PREFIX, ""))
                    .getBody()
                    .getSubject();

            if (user != null) {
                return new UsernamePasswordAuthenticationToken(user, null, new ArrayList<>());
            }
            return null;
        }
        return null;
    }
}
```
### UserDetailsService
Spring SecurityがDBを参照してユーザー情報を取得するサービス。

```UserDetailsServiceImpl.java
@Component
@Transactional
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private MstStudentRepository MstStudentRepository;
    @Override
    public UserDetails loadUserByUsername(String mailAddress)
            throws UsernameNotFoundException {

        // メールアドレスからユーザー情報を取得
        MstStudent user = MstStudentRepository.findByMailAddress(mailAddress)
        		.stream().findFirst().orElse(null);

        // ユーザー情報を取得できなかった場合
        if(user == null){
            throw new UsernameNotFoundException("ユーザーがみつかりません");
        }

        // ユーザー情報が取得できたらSpring Securityで認証できる形で戻す
        return new LoginUserDemo(user);
    }

}

```
### LoginUserDemo(Spring Securityと連動するユーザー情報格納クラス)


```LoginUserDemo.java
public class LoginUserDemo extends User {

    private MstStudent MstStudent;

    public LoginUserDemo(MstStudent user){
        super(user.getMailAddress(), user.getPassword(),
                AuthorityUtils.createAuthorityList("ROLE_USER"));
        this.MstStudent = user;
    }

    public MstStudent getMstStudent(){
        return this.MstStudent;
    }
}

```


# 実装（Angular側）
### LoginComponent(ログイン画面)
入力値制限やログイン失敗時のメッセージ、CSSでの見た目の調整などは何もしていません。今回は正常系が動くことを目標に実装しました。
メールアドレスとパスワードを入力し、サーバーへ送信。

```login.component.html
<form [formGroup]="loginForm" (ngSubmit)="submit()">
  <div>
    <label>メールアドレス</label><input type="text" formControlName="loginId">
  </div>
  <div>
    <label>パスワード</label><input type="password" formControlName="password">
  </div>
  <div>
    <button [disabled]="loginForm.invalid">ログイン</button>
  </div>
</form>
```
```login.component.ts
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { AjaxService } from '@service/ajax.service';
import { LoginService } from '@service/login.service';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.sass']
})
export class LoginComponent implements OnInit {

  constructor(
    private builder: FormBuilder,
    private login: LoginService
  ) { }

  loginForm: FormGroup = this.builder.group({
    'loginId': ['', Validators.required],
    'password': ['', [Validators.required]],
  });

  ngOnInit() {
  }

  submit() {
    // loginServiceにフォーム入力内容と、ログイン成功時のurlを指定
    this.login.login(this.loginForm.value, '/loggedInField');
  }
}

```

### LoginService(ログイン回りの処理を司るサービス)
- ログイン認証後、トークンをlocalStorageに保存
- トークンがない or トークンの有効期限が切れている => ログイン外とみなす
- 上記以外 => ログイン状態とみなす

```login.service.ts
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { HttpClient, HttpResponse } from '@angular/common/http';
import { CommonConstant } from '@constant/CommonConstant';

@Injectable({
  providedIn: 'root'
})
export class LoginService {

  constructor(
    private router: Router,
    private http: HttpClient
  ) { }


  // 失敗時の処理は今回は実装していません
  login(data: { loginId: string, password: string }, successUrl: string) {
    // 認証情報をサーバーにpost
    this.http.post(CommonConstant.HOST + '/authenticate',
      data,
      { observe: 'response' })
      .subscribe(res => {
        // ログイン成功時はトークンをlocalStorageに保持する
        this.storeToken(res);
        // ログイン成功時のページに遷移
        this.router.navigate([successUrl]);
      });
  }

  // ログアウト処理
  // localStorageのトークンを破棄してログイン画面に遷移
  logout() {
    localStorage.removeItem(CommonConstant.LocalStorage.JWT);
    this.redirectToLoginPage();
  }

  // ログイン状態かどうか確認し、ログイン状態でなければログイン画面に遷移する
  checkLoggedIn(): boolean {
    const isLoggedIn = this.isLoggedIn;
    if (!isLoggedIn) {
      this.redirectToLoginPage();
    }
    return isLoggedIn;
  }

  // レスポンスヘッダーからトークンを取りだしてローカルストレージに保存
  storeToken<T>(res: HttpResponse<T>) {
    const token = res.headers.get('Authorization');
    localStorage.setItem(CommonConstant.LocalStorage.JWT, token);
  }

  // ログイン画面に遷移する
  private redirectToLoginPage() {
    this.router.navigate(['/login']);
  }


  // トークンがあり、有効期限が切れていなければログイン状態とみなす
  get isLoggedIn(): boolean {
    return !this.isLoggedOut;
  }

  // トークンがないか、有効期限が切れていたらログアウト状態とみなす
  get isLoggedOut(): boolean {
    if (!this.token) {
      return true;
    } else {
      const payLoad: { sub: string, exp: number } = JSON.parse(window.atob(this.token.split('.')[1]));
      const expireSec = payLoad.exp;
      return expireSec < new Date().getTime() / 1000;
    }
  }

  // localStorageからトークンを取得
  get token(): string {
    const token = localStorage.getItem(CommonConstant.LocalStorage.JWT);
    return token ? token : '';
  }
}

```
### AjaxService
ajax共通処理サービス。

- 通信をしようとしたときにlocalStorageにトークンがなかったり有効期限が切れていたら通信を行わずログイン画面に遷移
- 常にJWTをヘッダーにつけて通信
- 通信が成功したら、新しいJWTが返ってくるのでlocalStorageのトークンを更新
- httpClientはデフォルトでresponseBodyを取り出せるようになっていますが、ヘッダーからトークンを取得しなければならないので`observe: 'response'`を使用
- ただ、そのままだと使いにくいのでトークン取得後にmapでresponseBodyを取り出しやすいように加工

```ajax.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, empty } from 'rxjs';
import { tap, map } from 'rxjs/operators';
import { LoginService } from '@service/login.service';
import { CommonConstant } from '@constant/CommonConstant';

@Injectable({
  providedIn: 'root'
})
export class AjaxService {

  constructor(
    private http: HttpClient,
    private loginService: LoginService,
  ) { }

  // get用header
  get defaultHeader(): HttpHeaders {
    const header = this.authorizationHeader;
    return header;
  }

  // post用header
  get headerForWithJson(): HttpHeaders {
    const header = this.authorizationHeader.set('Content-Type', 'application/json');
    return header;
  }

  // トークンをつけたheader
  get authorizationHeader(): HttpHeaders {
    return new HttpHeaders({ 'Authorization': this.loginService.token });
  }

  // ajax通信先
  get host(): string {
    return CommonConstant.HOST ? CommonConstant.HOST : '';
  }

  // login状態でgetする場合の共通関数（ログアウト状態なら通信を行わずログイン画面に遷移）
  getLoggedIn<T>(url: string): Observable<T> {
    if (this.loginService.checkLoggedIn()) {
      return this.get<T>(url);
    } else {
      return empty();
    }
  }

  // login状態でpostする場合の共通関数（ログアウト状態なら通信を行わずログイン画面に遷移）
  postLoggedIn<T>(url: string, body: any): Observable<T> {
    if (this.loginService.checkLoggedIn()) {
      return this.post<T>(url, body);
    } else {
      return empty();
    }
  }

  // ajax時にレスポンスheaderからトークンを取得してlocalStorageに保存
  // ⇒ resをres.bodyに加工
  get<T>(url: string): Observable<T> {
    return this.http.get<T>(this.host + url, {
      headers: this.defaultHeader,
      observe: 'response'
    }).pipe(
      tap(res => this.loginService.storeToken(res)),
      map(res => res.body));
  }

  // ajax時にレスポンスheaderからトークンを取得してlocalStorageに保存
  // ⇒ resをres.bodyに加工
  post<T>(url: string, body: any): Observable<T> {
    return this.http.post<T>(this.host + url, body, {
      headers: this.headerForWithJson,
      observe: 'response'
    }).pipe(
      tap(res => this.loginService.storeToken(res)),
      map(res => res.body));
  }
}

```
# 最後に
間違い、ご指摘などあれば教えてください。
今後、JWTの安全性についてもっとしっかり考えたいと思いました。
次はセッションを用いた従来通りの方法でログインの仕組みを実現したいです。
似たようなことをReact/Railsでやってみたいです。