概要
SPAとSpring Securityでログインの仕組みを作りたくてやってみました。
JWTについていろいろ調べて真似をすると動くものが作れましたが、一度ログインしてからずっと同じtokenを使うような方式だったので、ログイン後何かしら操作してもtokenの有効期限が更新されず、ログイン時に設定された有効期限を経過するとログアウトしてしまっていました。
そこで、よくある「最後に操作してから30分はログインが維持される」という方式のものを作りました。
具体的には、アクセスがあるたびにtokenを発行しなおすという手法をとりました。
もっといい方法をご存知の方がいらっしゃいましたらコメント欄にご意見いただけますと幸いです。
環境
- Spring Boot 2.1.1.RELEASE
- Spring Security
- jjwt 0.9.0
- Lombok
- modelmapper
- jpa
- Angular7
こちらを参考にさせていただきました
今回の投稿は、こちらの記事に掲載されているものを少しアレンジさせていただきました。
また、併せてangular側のコードを載せます。
JWTの仕組みついてはこちらを参考にさせていただきました。
実装(Spring Boot側)
SecurityConfig
- angularと連携するためにCORSを有効化
- JWTを使用するにあたって自作フィルターを設定
- JWTはhttpヘッダーにつけてやり取りする
あとはよくあるSpring Securityの設定です。
@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つのいずれにおいてもトークンを発行するので、トークン発行クラスを設定。
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を発行し、レスポンスヘッダーに付ける。
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の再発行)
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を参照してユーザー情報を取得するサービス。
@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と連動するユーザー情報格納クラス)
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での見た目の調整などは何もしていません。今回は正常系が動くことを目標に実装しました。
メールアドレスとパスワードを入力し、サーバーへ送信。
<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>
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 トークンの有効期限が切れている => ログイン外とみなす
- 上記以外 => ログイン状態とみなす
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を取り出しやすいように加工
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でやってみたいです。