🔒 백엔드 로그인 - JWT 구현
최초 로그인 프로세스
- 필자의 지독한 글씨체와 그림 실력으로 조금이나마 jwt 토큰 흐름을 이해하기 위해 그려보았다.
- 최초 로그인 시
UserDetailService에서 사용자 정보 존재 여부를 확인 → 확인될 경우 userDetail 객체를 반환 받은 뒤 → JwtTokenProvider에서 AccessToken과 RefreshToken을 생성 → Client에게 반환하는 흐름이다.
로그인 이후 요청 처리 프로세스
- 로그인 된 상태에서 HTTP 요청을 서버에 보낼 때의 흐름을 마찬가지로 필자의 지독한 필체와 그림으로 표현했다.
- 로그인된 상태에서 HTTP 요청을 보낼 땐
LocalStorage에 담긴 사용자의 Token 정보를 Authorization Header에 담아 보낸다.
Server가 Token을 받으면 JwtAuthenticationFilter에서 doFilterInternal → JwtTokenProvider → Token 추출을 거친다. → 해당 Token이 유효한지 검사 → 유효할 경우 getAuthentication를 통해 Authentication 객체를 생성해 SecurityContextHolder에 저장한다.
SecurityContextHolder에 인증 객체가 저장된 상태이기 때문에 Principal 등을 이용해 controller에서 사용자 정보를 쉽게 조회할 수 있게 된다.
Jwt 로그인 구현
JwtToken
@Getter
@ToString
@Data
@AllArgsConstructor
public class JwtToken {
private String accessToken;
private String refreshToken;
}
AuthServiceImpl
@Service
@Transactional
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
@Override
@Transactional
public JwtToken signIn(UserLoginDto userLoginDto) {
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userLoginDto.getEmail(), userLoginDto.getPassword());
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(auth);
String accessToken = jwtTokenProvider.createToken(userLoginDto.getEmail(), authentication.getAuthorities(), jwtTokenProvider.getAccessTokenExpirationTime());
String refreshToken = jwtTokenProvider.createRefreshToken(userLoginDto.getEmail());
return new JwtToken(accessToken, refreshToken);
}
@Override
public void logout(String token){
String accessToken = jwtTokenProvider.resolveToken(token);
if(!jwtTokenProvider.validateToken(accessToken)){
throw new UserException(ExceptionMessage.INVALID_ACCESS_TOKEN);
};
jwtTokenProvider.addBlackList(accessToken);
jwtTokenProvider.deleteRefreshToken(accessToken);
}
@Override
public JwtToken refresh(String token) {
String refreshToken = jwtTokenProvider.resolveToken(token);
if(refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)){
throw new UserException(ExceptionMessage.INVALID_REFRESH_TOKEN);
};
User user = userRepository.findByEmail(jwtTokenProvider.getUserName(refreshToken))
.orElseThrow(() -> new UserException(ExceptionMessage.USER_NOT_FOUND));
Collection<GrantedAuthority> authorities = user.getUserType() != null ?
List.of(new SimpleGrantedAuthority(user.getUserType().name())) :
List.of();
return new JwtToken(
jwtTokenProvider.createToken(user.getEmail(), authorities, jwtTokenProvider.getAccessTokenExpirationTime()),
refreshToken
);
}
}
UserDetailService
@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<User> user = userRepository.findByEmail(username);
if(user.isPresent()) {
return new PrincipalDetails(user.get());
}
throw new UsernameNotFoundException("해당 이메일을 갖는 사용자가 존재하지 않습니다: " + username);
}
}
JwtAuthenticationFilter
- Filter의 경우 어떤 요청은 통과시키고 어떤 요청은 Filter를 거치게 만들어야 해서 생각보다 꼬이는 일이 많았다.
- 코드가 약.. 간 지저분한 느낌이 조금 있다.
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final AuthService authService;
private static final List<String> EXCLUDED_PATHS = List.of(
"/swagger-ui/**", "/v3/api-docs/**", "/bonbon/user/login", "/bonbon/email/send", "/bonbon/email/verify",
"/bonbon/user/email-check", "/bonbon/user/headquarters", "/bonbon/user/franchisee/without-owner",
"/bonbon/user/region", "/health", "/actuator/health", "/files/upload"
);
private static final AntPathMatcher pathMatcher = new AntPathMatcher();
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (isExcludedRequest(request)) {
filterChain.doFilter(request, response);
return;
}
// 받은 Request 의 Authorization 헤더에서 Token만 Parsing 해서 추출
String token = jwtTokenProvider.resolveToken(request.getHeader("Authorization"));
if (isValidToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// 얻은 authentication 객체를 SecurityContextHolder에 넣어줌
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} else {
handleInvalidToken(response);
}
}
private boolean isExcludedRequest(HttpServletRequest request) {
String path = request.getRequestURI();
String method = request.getMethod();
return EXCLUDED_PATHS.stream().anyMatch(pattern -> pathMatcher.match(pattern, path))
|| ("POST".equalsIgnoreCase(method) && (
"/bonbon/user/franchisee".equals(path) || "/bonbon/user/manager".equals(path)
));
}
// token 유효 여부 확인
private boolean isValidToken(String token) {
return token != null &&
jwtTokenProvider.validateToken(token) &&
jwtTokenProvider.hasRoleClaim(token) &&
!jwtTokenProvider.isBlackListed(token);
}
// 토큰이 유효하지 않은 경우
private void handleInvalidToken(HttpServletResponse response) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("JWT Token is invalid or blacklisted");
}
}
AuthController
@Slf4j
@RestController
@RequestMapping("/bonbon/user")
@RequiredArgsConstructor
@Tag(name = "Auth", description = "로그인/로그아웃")
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
@Operation(summary = "로그인", description = "이메일, 비밀번호로 로그인한다.")
public ResponseEntity<JwtToken> signIn(
@Valid @RequestBody UserLoginDto userLoginDto){
JwtToken jwtToken = authService.signIn(userLoginDto);
return ResponseEntity.ok(jwtToken);
}
@PostMapping("/logout")
@Operation(summary = "로그 아웃", description = "AccessToken 정보를 바탕으로 로그아웃 한다.")
public ResponseEntity<Void> logOut(
@RequestHeader("Authorization") String token
){
authService.logout(token);
return ResponseEntity.noContent().build();
}
@PostMapping("/refresh")
@Operation(summary = "토큰 재발급", description = "AccessToken을 Refresh Token 정보를 바탕으로 재발급한다.")
public ResponseEntity<JwtToken> refresh(
@RequestHeader("Authorization") String token
){
JwtToken refreshToken = authService.refresh(token);
return ResponseEntity.ok(refreshToken);
}
}
AuthServiceImpl
@Slf4j
@RestController
@RequestMapping("/bonbon/user")
@RequiredArgsConstructor
@Tag(name = "Auth", description = "로그인/로그아웃")
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
@Operation(summary = "로그인", description = "이메일, 비밀번호로 로그인한다.")
public ResponseEntity<JwtToken> signIn(
@Valid @RequestBody UserLoginDto userLoginDto){
JwtToken jwtToken = authService.signIn(userLoginDto);
return ResponseEntity.ok(jwtToken);
}
@PostMapping("/logout")
@Operation(summary = "로그 아웃", description = "AccessToken 정보를 바탕으로 로그아웃 한다.")
public ResponseEntity<Void> logOut(
@RequestHeader("Authorization") String token
){
authService.logout(token);
return ResponseEntity.noContent().build();
}
@PostMapping("/refresh")
@Operation(summary = "토큰 재발급", description = "AccessToken을 Refresh Token 정보를 바탕으로 재발급한다.")
public ResponseEntity<JwtToken> refresh(
@RequestHeader("Authorization") String token
){
JwtToken refreshToken = authService.refresh(token);
return ResponseEntity.ok(refreshToken);
}
}