Search
🔐

JWT 를 이용한 Security 인증 / 인가 구현기

태그
JWT
spring security
redis
cookie
분류
Spring Boot
목차

필터의 순서 정하기

Spring Security 동작 과정

요청이 들어오면 그림과 같이 AuthenticationFilter 를 거쳐 UsernamePasswordAuthenticationToken 을 만들고 이를 이용하여 그 다음 과정을 쭉쭉 거치게 됩니다. 결론적으로는 SecurityContextHolder 에 인증이 완료된 유저네임과 패스워드가 저장되게 되죠. 그렇다면 JWT 필터는 어떤가요?

JWT 필터

JWT 필터도 별반 다르지 않습니다. 요청이 들어오면 요청 헤더에 들어있는 토큰을 꺼내어 토큰 검증을 거칩니다. 유효한 토큰이라면 아래와 같이 시큐리티에 건내줄 수 있습니다.
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user, accessToken, new ArrayList<>()); SecurityContextHolder.getContext().setAuthentication(authenticationToken); }
Java
복사

결론은요?

요청이 들어오면 JWT 필터를 거치고 시큐리티 필터를 거치도록 하였습니다. 토큰을 먼저 꺼내보고 유효하지 않은 경우 아예 접근 자체를 못하게 하기 위해서였습니다.

JWT

AccessToken 은 Authorization 헤더에, RefreshToken 은 쿠키와 레디스에 넣는 방법을 사용했습니다.

JWTAuthenticationFilter.java

이곳에서는 JWT 필터를 거치는 api 와 거치지 않는 api 를 설정할 수 있습니다.
package com.planner.travel.global.jwt; import com.planner.travel.global.jwt.token.TokenAuthenticator; import com.planner.travel.global.jwt.token.TokenExtractor; import com.planner.travel.global.jwt.token.TokenValidator; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @RequiredArgsConstructor public class JWTAuthenticationFilter extends OncePerRequestFilter { private final TokenExtractor tokenExtractor; private final TokenValidator tokenValidator; private final TokenAuthenticator tokenAuthenticator; @Override protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException { String requestURI = request.getRequestURI(); if (requestURI.equals("/api/v1/auth/signup") || requestURI.equals("/api/v1/auth/login") || requestURI.startsWith("/api/v1/auth/token") || requestURI.startsWith("/api/v1/docs") ) { filterChain.doFilter(request, response); return; } String accessTokenFromHeader = tokenExtractor.getAccessTokenFromHeader(request); String accessToken = null; if (accessTokenFromHeader != null) { accessToken = accessTokenFromHeader; } tokenValidator.validateAccessToken(accessToken); tokenAuthenticator.getAuthenticationUsingToken(accessToken); filterChain.doFilter(request, response); } }
Java
복사

TokenExtractor.java

TokenValidator.java

TokenAuthenticator.java

JWTRefreshService.java

AccessToken 이 만료 될 경우, 쿠키에서 refreshToken 을 꺼내어 유저를 검증합니다.
검증된 유저의 경우 AccessToken 과 refreshToken 이 갱신됩니다.
package com.planner.travel.global.jwt; import com.planner.travel.global.jwt.token.SubjectExtractor; import com.planner.travel.global.jwt.token.TokenGenerator; import com.planner.travel.global.jwt.token.TokenType; import com.planner.travel.global.util.CookieUtil; import com.planner.travel.global.util.RedisUtil; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Service @Slf4j @RequiredArgsConstructor public class JWTRefreshService { private final CookieUtil cookieUtil; private final RedisUtil redisUtil; private final SubjectExtractor subjectExtractor; private final TokenGenerator tokenGenerator; public void refreshAccessToken(HttpServletRequest request, HttpServletResponse response) { Cookie cookieFromRequest = cookieUtil.getCookie(request, "refreshToken"); String refreshToken = cookieFromRequest.getValue(); String userId = subjectExtractor.getUesrIdFromToken(refreshToken).toString(); log.info("======================================================"); log.info("refreshToken: " + refreshToken); log.info("userId: " + userId); log.info("======================================================"); String redisValue = redisUtil.getData(userId); if (refreshToken.equals(redisValue)) { String newAccessToken = tokenGenerator.generateToken(TokenType.ACCESS, userId); String newRefreshToken = tokenGenerator.generateToken(TokenType.REFRESH, userId); response.setHeader("Authorization", newAccessToken); cookieUtil.setCookie("refreshToken", newRefreshToken, response); redisUtil.setData(userId, newRefreshToken); } } }
Java
복사

KeyConfiguration.java

SubjectExtractor.java

TokenGenerator.java

CookieUtil.java

RedisUtil.java

JWTController.java

package com.planner.travel.global.jwt; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping(value = "/api/v1/auth/token") @RequiredArgsConstructor public class JWTController { private final JWTRefreshService jwtRefreshService; @PostMapping(value = "/refresh") public void refreshAccessToken(HttpServletRequest request, HttpServletResponse response) { jwtRefreshService.refreshAccessToken(request, response); } }
Java
복사

Security

시큐리티에서 제공하는 form 로그인을 사용하지 않고, 로그인 리퀘스트로 받은 값을 그대로 사용하려면 시큐리티를 커스텀 해야 합니다. 그래서 시큐리티의 동작과정에서 보이는 클래스들을 아래와 같이 커스텀 하였습니다.

CustomAuthenticationFilter.java

로그인 리퀘스트를 통해 아이디와 비밀번호를 받습니다.
이를 이용하여 UsernamePasswordAuthenticationToken 을 만듭니다.
package com.planner.travel.global.security; import com.fasterxml.jackson.core.exc.StreamReadException; import com.fasterxml.jackson.databind.DatabindException; import com.fasterxml.jackson.databind.ObjectMapper; import com.planner.travel.domain.user.dto.request.LoginRequest; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import java.io.IOException; @Slf4j public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter { public CustomAuthenticationFilter(final AuthenticationManager authenticationManager) { super.setAuthenticationManager(authenticationManager); } @Override public Authentication attemptAuthentication(final HttpServletRequest request, final HttpServletResponse response) { final UsernamePasswordAuthenticationToken authenticationToken; try { final LoginRequest loginRequest = new ObjectMapper().readValue(request.getInputStream(), LoginRequest.class); log.info("==========================================================================="); log.info("Login user's email : " + loginRequest.email()); log.info("==========================================================================="); authenticationToken = new UsernamePasswordAuthenticationToken(loginRequest.email(), loginRequest.password()); } catch (IOException e) { throw new RuntimeException(e); } setDetails(request, authenticationToken); return this.getAuthenticationManager().authenticate(authenticationToken); } }
Java
복사

CustomAuthenticationProvider.java

UsernamePasswordAuthenticationToken 으로부터 아이디와 비밀번호를 조회합니다.
CustomUserDetailsService 를 사용하여 아이디로 사용자를 조회하고, 존재한다면 이를 이용하여 새로운 UsernamePasswordAuthenticationToken 을 생성합니다.
package com.planner.travel.global.security; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @RequiredArgsConstructor public class CustomAuthenticationProvider implements AuthenticationProvider { private final CustomUserDetailsService customUserDetailsService; private final BCryptPasswordEncoder bCryptPasswordEncoder; @Override public Authentication authenticate(Authentication authentication) { final UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication; final String email = token.getName(); final String password = (String) token.getCredentials(); final CustomUserDetails userDetails; userDetails = (CustomUserDetails) customUserDetailsService.loadUserByUsername(email); if (!bCryptPasswordEncoder.matches(password, userDetails.getPassword())) { throw new BadCredentialsException("AUTH_02"); } return new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities()); } @Override public boolean supports(Class<?> authentication) { return authentication.equals(UsernamePasswordAuthenticationToken.class); } }
Java
복사

왜 UsernamePasswordAuthenticationToken 에 userDetails 을 넣나요?

인텔리제이에서 UsernamePasswordAuthenticationToken 을 한번 찾아가보면, 아래와같이 나옵니다.
파라미터를 보면 principal 은 Object 입니다. 그래서 유저의 아이디나 이메일같이 유저의 특정값이 아닌, 유저를 넣어주었습니다. 이 principal 은 아래와 같이 인증이 완료된 유저의 인증 정보를 꺼내올 때 사용할 수 있습니다.
public Long getLoginUserIndex() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Object principal = authentication.getPrincipal(); Long userId = ((User) principal).getId(); return userId; }
Java
복사
유저의 어떤 정보가 필요할 지 몰라서 User 자체를 넣어준 것이죠

CustomUserDetailsService.java

토큰을 통해 찾아온 이메일을 이용하여 특정 유저가 존재하는지 확인합니다.
만약 유저가 존재한다면, 이를 CustomUserDetails 에 넣어 반환 합니다.
package com.planner.travel.global.security; import com.planner.travel.domain.user.entity.User; import com.planner.travel.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Service; import java.util.Optional; @Service @RequiredArgsConstructor @Slf4j public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; @Override public UserDetails loadUserByUsername(String email) { Optional<User> user = userRepository.findByEmail(email); return new CustomUserDetails(user.orElseThrow()); } }
Java
복사

CustomUserDetails.java

사용자를 저장합니다.
package com.planner.travel.global.security; import com.planner.travel.domain.user.entity.User; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; import java.util.Collections; public record CustomUserDetails(User user) implements UserDetails { @Override public Collection<? extends GrantedAuthority> getAuthorities() { return Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")); } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getEmail(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
Java
복사

총 정리

로그인 이후 요청이 들어올 때, JWTAuthenticationFilter 를 먼저 거치게 됩니다.
리퀘스트 URI 가 토큰 검증을 거쳐야 하는 URI 인 경우 accessToken 을 검증합니다.
accessToken 의 검증이
제대로 완료된 경우, 유저에 대한 정보를 security 에 넘겨줍니다.
실패한 경우 유저 인증에 실패 했으므로, 403 을 반환합니다.
security 는 받은 정보를 사용하여 인가 과정을 거칩니다.
인가 과정에 실패한 경우 403 을 반환합니다.