はじめに
Spring SecurityとJWTを組み合わせた認証・認可機能を実装するときの流れをまとめる。
今回はDaoAuthenticationProviderを用いて、データベースからユーザー情報を取得し、
JWTを使って認証状態を管理する。
Spring Securityでのリクエストの処理フロー
Spring Securityでは、リクエストが複数のフィルターを通過する。
これらのフィルターが順番にリクエストを処理し、必要な認証や認可を行う。
処理のシナリオとしては以下のようになる。
1. ログイン時の処理
UsernamePasswordAuthenticationFilterがリクエストを処理。
↓
フォームデータからユーザー名とパスワードを抽出。
↓
DaoAuthenticationProviderがデータベースからユーザー情報を取得し、パスワードを検証。
↓
認証成功時、JWTを生成しクライアントに返却。
↓
クライアントはJWTを保持し、次回以降のリクエストで使用。
2. JWTを持った状態でのリクエスト処理
JwtAuthenticationFilterがリクエストを処理。
↓
リクエストヘッダーからJWTを抽出し、検証。
↓
トークンが有効であれば、ユーザー情報を抽出し、SecurityContextHolderに設定。
↓
UsernamePasswordAuthenticationFilterはスキップされ、リクエストが正常に処理される。
↓
認証が必要なエンドポイントにアクセス可能。
実装
JwtAuthenticationFilter
Jwtを使用する場合は独自のフィルターを作成する必要がある。
フィルターは以下のように作成した。
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
// リクエストからJWTを取得
String jwt = jwtTokenProvider.getJwtFromReuest(request);
// JWTが存在し、有効であれば処理を続ける
if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
// JWTからユーザー名を取得
String username = jwtTokenProvider.getUsernameFromJwt(jwt);
// UserDetailsをuserDetailsServiceから取得
UserDetails user = userDetailsService.loadUserByUsername(username);
// ユーザーの情報を持つAuthenticationオブジェクトを作成
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
// SecurityContextにAuthentication情報を設定
SecurityContextHolder.getContext().setAuthentication(auth);
}
// フィルターチェーンを続行
filterChain.doFilter(request, response);
} catch (RuntimeException ex) {
// RuntimeExceptionが発生した場合、セキュリティコンテキストをクリア
SecurityContextHolder.clearContext();
// エラーレスポンスを処理
handleErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage());
} catch (Exception e) {
// その他の例外が発生した場合、セキュリティコンテキストをクリア
SecurityContextHolder.clearContext();
// 一般的なエラーレスポンスを処理
handleErrorResponse(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "An unexpected error occurred");
}
}
上記によって、JWTが存在し有効であれば認証処理が行われ、次のフィルターへと移る。
JWTが存在しないまたは無効の場合は、認証処理がスキップされ、filterChain.doFilter(request, response)が呼び出されることになる。この時、次のフィルターへ移行し、JWTを持たないユーザーは、認証が必要なエンドポイントにアクセスできないように制限される。
SecurityConfig
作成したフィルターをフィルターチェーンに追加する。
SecurityConfigを作成し、以下を記述する。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private CustomUserDetails userDetails;
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf->csrf.disable())
// Jwtを使用する場合は、リクエストごとにセッションを確立する必要はない。
.sessionManagement(session->session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// "/api/auth/**"は認証時に使用するエンドポイントのため許可、
// その他のエンドポイントへのリクエストは認証が必要とした。
.authorizeHttpRequests(auth->auth.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated())
// jwtAuthenticationFilterを先に配置することで、リクエストごとにJWTを解析して認証を済ませることができる。
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
AuthenticationManagerBuilder auth = http.getSharedObject(AuthenticationManagerBuilder.class);
// AuthenticationProviderとしてDaoAuthenticationProviderを使用する
auth.authenticationProvider(authenticationProvider());
return auth.build();
}
@Bean
DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetails);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
JWTを持ったリクエストがログインフィルターの前に処理されるようにaddFilterBeforeメソッドを使用する。これによって、UsernamePasswordAuthenticationFilterの前に処理されるようにしている。
JwtUtil
Jwtで使用するメソッドをまとめたクラスを作成する。
@Component
public class JwtTokenProvider {
@Value("${security.jwt.token.expiration-ms}")
private Long jwtExpirationMs;
@Value("${security.jwt.token.secretkey}")
private String jwtSecretKey;
private Key getSignWithKey(){
byte[] decodedKey = Decoders.BASE64.decode(jwtSecretKey);
return Keys.hmacShaKeyFor(decodedKey);
}
public String getUsernameFromJwt(String token){
return Jwts.parserBuilder()
.setSigningKey(getSignWithKey())
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public long getExpirationMs(){
return jwtExpirationMs;
}
public boolean validateToken(String token){
try {
// parseClaimsJws(token)でtokenを検証してくれる。
// もし失敗したらJwtExceptionがthrowされる
Jwts.parserBuilder().setSigningKey(getSignWithKey()).build().parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
throw new CustomException("Jet error","Expired or invalid JWT token",HttpStatus.UNAUTHORIZED);
}
}
public String getJwtFromReuest(HttpServletRequest req){
String bearerToken = req.getHeader("Authorization");
// Tokenがnullでなく、"Bearer"から始まっていれば"Bearer "を取り除く
if(bearerToken != null && bearerToken.startsWith("Bearer ")){
return bearerToken.substring(7);
}
return null;
}
public String generateToken(Authentication authentication){
// 認証情報からusernameを取ってくる
String username = authentication.getName();
// ペイロードのissに現在のDateをセットするために使用する
Date now = new Date();
// ペイロードのexpに現在のDateにexpiration-msの値を足した値をセットするために使用する
Date expiryDate = new Date(now.getTime() + jwtExpirationMs);
return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSignWithKey(), SignatureAlgorithm.HS512)
.compact();
}
}
Controller、Service
以下のようなcontroller、serviceを作成する。
//ログイン、サインアップ時のエンドポイント
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthService authService;
@PostMapping("/signup")
public ResponseEntity<User> signup(@Valid @RequestBody SignupRequestDto signupRequestDto) {
User signupUser = authService.signup(signupRequestDto);
return ResponseEntity.ok(signupUser);
}
@PostMapping("/login")
public ResponseEntity<ResponseDto> login(@RequestBody LoginRequestDto loginRequestDto) {
ResponseDto response = authService.authenticateUser(loginRequestDto);
return ResponseEntity.ok(response);
}
}
//認証後にアクセス可能なエンドポイント
@RestController
@RequestMapping("/home")
public class HomeController {
@GetMapping
public String hello(@AuthenticationPrincipal UserDetails userDetails) {
return "hello, " + userDetails.getUsername();
}
}
@Service
public class AuthService {
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtTokenProvider jwtTokenProvider;
public User signup(SignupRequestDto signupRequestDto){
String username = signupRequestDto.getUsername();
String email = signupRequestDto.getEmail();
String hashedPassword = passwordEncoder.encode(signupRequestDto.getPassword());
if(userRepository.existsByUsername(username)){
throw new CustomException("Conflict","this username is already in use!",HttpStatus.CONFLICT);
}
if(userRepository.existsByEmail(email)){
throw new CustomException("Conflict","this email is already in use!",HttpStatus.CONFLICT);
}
User user = new User();
user.setUsername(username);
user.setEmail(email);
user.setPassword(hashedPassword);
return userRepository.save(user);
}
public ResponseDto authenticateUser(LoginRequestDto loginRequestDto){
try {
// DaoAuthenticationProviderでの認証
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequestDto.getUsername(), loginRequestDto.getPassword()
)
);
SecurityContextHolder.getContext().setAuthentication(auth);
// 認証成功時にJwtを生成しクライアントへ返す
String jwt = jwtTokenProvider.generateToken(auth);
ResponseDto response = new ResponseDto();
response.setToken(jwt);
response.setExpiresIn(jwtTokenProvider.getExpirationMs());
return response;
} catch (AuthenticationException e) {
// 認証失敗時の処理
throw new CustomException("Authentication Error","Invalid username or password",HttpStatus.UNAUTHORIZED);
}
}
}
デモ
signupでユーザを作成後、localhost:8080/api/auth/loginに作成したユーザ情報でリクエストを送る。
例:
{
"username":"user",
"password":"password"
}
すると以下のようにJwtトークンが返ってくる。
{
"token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VydXNlcjIyMiIsImlhdCI6MTczMDgzNTk1MiwiZXhwIjoxNzMwODM2MjUyfQ.X1DtzgHlA9_5JD00Reqxoj2gZi6up8BPERpb2aNxnPMMEQdPoiCSIQqxCNB80cYiiDFZrz9Y9cI1dmVIZK7kZQ",
"expiresIn": 300000
}
上記のトークンをAuthorizationに設定し、localhost:8080/homeにリクエストを送る。
すると、以下の通りログインしたユーザ情報を持つレスポンスが返ってくる。
hello, user
参考
以下の記事および動画を参考にさせていただいた。
https://www.codeflow.site/ja/article/spring-security-authentication-with-a-database
https://medium.com/@tericcabrel/implement-jwt-authentication-in-a-spring-boot-3-application-5839e4fd8fac
https://www.youtube.com/watch?v=KxqlJblhzfI