티스토리 뷰

들어가기 전 설명

 

현재 구현한 코드들은 GitHub 에 있습니다. 글을 보다가 헷갈리시면 소스코드로 참고해주시기 바랍니다.

 

 

 

 

 

개발 환경

 

JAVA17
Spring Boot 3.1.8
Spring Security 6.1.6
Spring JPA 3.1.8
Mac OS
JAVA JWT 4.4.0
Redis 7.4.0
IntelliJ 2024.01.04v

 

 

 

 

 

작업 순서

 

  1. JWT accessToken 인증 필터 구현
  2. JWT 인증필터 검증
  3. refreshToken도 추가로 발급하며 Redis에 저장
  4. refreshToken로 accessToken 재발급 (API 개발)
  5. 로그아웃
  6. 추가적 구상

 

 

 

 

 

1. JWT accessToken AuthenticationFilter 구현

 

추상 클래스인 OncePerRequestFilter는 하나의 HTTP 요청에 대해 단 한 번만 실행됩니다.

그리하여 이 클래스를 구현하여 요청 할 때마다 1번씩 실행하게 만드는 필터를 만들어서, JWT를 검증하는 로직을 넣을 것입니다.

 

이제 로그인 한사람은 페이지를 이동하던가 API를 호출하면 다 걸리는거야 ^^

 

JwtAuthenticationFilter

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtHelper jwtHelper;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // accessToken 값 가져오기
        String accessToken = "";
        Optional<Cookie> optionalCookie = Optional.ofNullable(request.getCookies())
                .map(cookies -> Arrays.stream(cookies)
                        .filter(cookie -> cookie.getName().equals("accessToken"))
                        .findFirst())
                .orElse(Optional.empty());
        if (optionalCookie.isPresent()) {
            accessToken = optionalCookie.get().getValue();
        }

        // 토큰이 없으면 다음 필터로 넘겨 ~
        if(accessToken.isEmpty()){
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰 검증
        if(!jwtHelper.validation(accessToken)) {
            
            // Front 와 협의하여 어떻게 내려줄지 해야한다.
            response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("유효하지 않은 토큰입니다.");

            return;
        }
        // 토큰에서 정보 획득
        String email = jwtHelper.extractSubject(accessToken);
        UserRole role = UserRole.valueOf(jwtHelper.extractRole(accessToken));


        User user = new User(email, "password", role);

        CustomUserDetails customUserDetails = new CustomUserDetails(user);

        JwtAuthenticationToken authToken = new JwtAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());

        SecurityContextHolder.clearContext();
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);

    }
}

 

JWT 검증 메서드 로직 순서

  1. 요청한 사용자에게 쿠키값을 뽑아옵니다.
  2. accessToken이 없다면 비로그인한 사람이기 때문에 다음 필터로 넘깁니다.
  3. JWT 검증을 실시합니다.
  4. JWT에서 정보를 뽑아서 SpringSecurityContext에 일시적으로 저장합니다.
    왜?! JWT를 사용하면 인증/인가 주도권이 사용자에게 있기 때문에 서버가 주도권을 가지기 위해 일시적으로 세션을 만듭니다.
  5. 다음 필터로 넘깁니다.

 

구현한 필터 SecurityConfig에 필터 등록

이 필터는 로그인하는 필터 앞에다가 세팅해주면 됩니다. 

내용은 삭제하였고 이전에 로그인 인증 필터 앞에다가 놓기위해서 addFilterBefore 메서드를 사용하였습니다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http, JwtHelper jwtHelper) throws Exception{
	// ~~ 중간 생략~
	http
        .addFilterBefore(new JwtAuthenticationFilter(jwtHelper), LoginAuthenticationFilter.class)
        .addFilterAt(loginAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}

 

 

 

 

 

 

2. JWT 인증 필터 검증

 

이제 JWT인 accessToken을 가지고 있는 사용자라면 저의 서버에 어떤한 요청이든 넣을 수 있게 되었습니다.

하지만 어떠한 요청 경로는 어떠한 권한이 있어야만 된다고 설정을 하였을 때 잘 작동하는지 보겠습니다.

 

SecurityConfig 내 filterChain메서드 내부 중 설정 1개

http.authorizeHttpRequests((auth) -> auth
                .requestMatchers(ALL_URL).permitAll()
                .requestMatchers("/api/admin").hasAnyAuthority(UserRole.ADMIN.getCode())
                .anyRequest().authenticated());

 

 

권한이 ADMIN인 유저가 권한이 있는 요청을 하였을 때

Spring Security 권한별 요청
그림1 ADMIN권한은 요청 성공

 

 

권한이 USER인 유저가 권한이 있는 요청을 하였을 때

Spring Security 권한별 요청
그림2 USER권한은 403 에러

 

 

 

 

 

 

중간에 생각을 해보자

 

이제 accessToken을 이용하는 건 잘 알아보았습니다.

근데 만약에 accessToken이 만료되면? 그때마다 재로그인을 할 것인가?

 

사용자들을 이탈하게 만드는 운영 방식이면 이렇 사용해도됩니다.

화나는 호동
그림3 으이그 호동

 

이런거 말고 이제 accessToken에는 사용하려는 정보들이 있어서 해커들에게 되도록 노출시키지 않으려고 유효 시간을 짧게 가져갑니다.

이 짧게 가져가는 대신에 accessToken이 만료되면 refreshToken으로 aceessToken을 재발급해주는 비즈니스가 생겼습니다.

 

 

토큰을 내려준다면 저장 위치가 어디가 좋을까?

  • AccessToken : 대체로 헤더로 내려주기 때문에 localStorage에 저장됩니다. 하지만 XSS 공격에 노출 될 수 있습니다.
  • RefreshToken : HttpOnly를 이용하여 쿠키로 내려줍니다. 하지만 CSRF 공격에 취약합니다.

저는 AccessToken, RefreshToken 보여주기 위해 쿠키로 내려주고 있습니다.

이제 RefreshToken을 발급하고 이 사용법을 알아보도록 하겠습니다.

 

 

 

 

3. 로직을 수정하여 로그인 했을 때 RefreshToken도 같이 발급하며 Redis에 저장

 

 

인증을 성공하여 로그인이 되었을 때 AuthenticationSuccessHandler를 구현한 LoginSuccessHandler에서 accessToken을 내려주고 있었습니다. 이제는 refreshToken도 같이 내려주시면 됩니다.

 

 

  • 재발급 받기위한 refreshToken을 같이 쿠키로 구워줍니다.
  • 사용자 email을 key값을 가지고 refreshToken 값을 value로 refreshToken 유효시간 만큼 Redis에 캐시해 줍니다.

 

 

LoginSuccessHandler 

@Component
@RequiredArgsConstructor
public class LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final JwtHelper jwtHelper;
    private final RedisService redisService;

    // [로그인 실행 5] 인증 성공하여 로그인 성공하면 실행하는 핸들러
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        String email = userDetails.getUsername();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        String role = authorities.stream()
                .findFirst()
                .map(GrantedAuthority::getAuthority)
                .orElse(UserRole.ADMIN.getCode());

        String accessToken = jwtHelper.generateAccessToken(email, role);
        String refreshToken = jwtHelper.generateRefreshToken(email);
        
        // Redis에 refreshToken 유효시간만큼 캐시
        redisService.save(email, refreshToken, Duration.ofMillis(jwtHelper.extractExpiredAt(refreshToken)));
        
        // 방법 1) 쿠키에 넣어서 전달
        Cookie accessTokenCookie = createCookie(accessToken, "accessToken");
        Cookie refreshTokenCookie = createCookie(refreshToken, "refreshToken");

        // 방법 2) HTTP header에 넣어서 전달
        //response.addHeader("Authorizetion", "Bearer " + accessToken);

        // 응답 메시지 작성
        String jsonResponse = new ObjectMapper().writeValueAsString(new LoginResponse(HttpServletResponse.SC_OK, "성공적으로 로그인이 되었습니다."));
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
        response.setStatus(HttpServletResponse.SC_OK);
        response.getWriter().write(jsonResponse);

        // 쿠키
        response.addCookie(accessTokenCookie);
        response.addCookie(refreshTokenCookie);
    }

    // 쿠키 내려주기
    private Cookie createCookie(String accessToken, String cookieName) {
		...
    }
}

 

 

 

 

 

 

4. refreshToken로 accessToken 재발급 (API 개발)

 

UserController

  • 간단한 유효성 검사를 해주었습니다.
  • Service에서 accessToken을 재발급 한 토큰 값을 쿠키에 다시 구워주었습니다.
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class UserController {

    private final UserService userService;
    private final JwtHelper jwtHelper;

    // 중간 메서드 생략

    @PostMapping("/token/refresh")
    public ResponseEntity<String> reissue(HttpServletRequest request, HttpServletResponse response,
                                          @CookieValue(name = "refreshToken", required = false) String refreshToken) {

        // 간단 유효성 검사 (1)
        if (refreshToken == null) {
            return ResponseEntity.badRequest().body("refreshToken null");
        }

        // 간단 유효성 검사 (2)
        if (!jwtHelper.validation(refreshToken)) {
            return ResponseEntity.badRequest().body("refreshToken expired");
        }

        try {
            String accessToken = userService.reissue(refreshToken);
            Cookie accessTokenCookie = createCookie(accessToken);

            // 쿠키 추가
            response.addCookie(accessTokenCookie);

            return ResponseEntity.ok().build();
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());
        }


    }

    private Cookie createCookie(String accessToken) {
        Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
        long expiration = jwtHelper.extractExpiredAt(accessToken);
        int maxAge = (int) ((expiration - new Date(System.currentTimeMillis()).getTime()) / 1000);
        accessTokenCookie.setMaxAge(maxAge);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setSecure(false);

        return accessTokenCookie;
    }
}

UserServiceImpl

  • RefreshToken에 있는 정보를 가지고 Redis를 조회하였습니다.
  • RefreshToken에 없는 부족한 정보는 DB에서 조회해서 넣었습니다.
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class UserServiceImpl implements UserService , UserDetailsService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final JwtHelper jwtHelper;
    private final RedisService redisService;

	// 중간 메서드 생략

    // 사용자 찾기
    @Override
    public User findUserByEmail(String email) {
        return userRepository.findByEmail(email);
    }

    // 토큰 재발급
    @Override
    public String reissue(String refreshToken) {
        // 유저 인증
        String email = jwtHelper.extractSubject(refreshToken);

        // refreshToken 레디스에서 찾기
        Optional<String> optionalToken = redisService.find(email);
        if (optionalToken.isPresent()) {
            // refreshToken 유효성 검증
            if (!optionalToken.get().equals(refreshToken)) {
                throw new RuntimeException("유효하지 않은 refreshToken 입니다.");
            }

            User user = findUserByEmail(email);
            return jwtHelper.generateAccessToken(email, user.getRole().getCode());
        } else {
            throw new RuntimeException("존재하지 않는 토큰 입니다.");
        }
    }

}

RedisService

  • Redis 조회, 저장, 삭제 메서드를 만들었습니다.
@Service
@RequiredArgsConstructor
public class RedisService {

    private final RedisTemplate<String, String> redisTemplate;

    // 값 넣기
    public void save(String key, String value, Duration ttl){
        redisTemplate.opsForValue().set(key, value, ttl);
    }

    // 값 가져오기
    public Optional<String> find(String key){
        return Optional.ofNullable(redisTemplate.opsForValue().get(key));
    }

    // 값 삭제하기
    public void delete(String key){
        redisTemplate.delete(key);
    }
}

 

 

 

이건 코드가 아니라 Redis에 직접 접속하여 값이 들어 있는지만 확인하겠습니다.

# 캐시된 모든 키 값 출력
KEYS *

# 해당 키로 캐시된 값 출력
get [KEY]

그림4 Redis 내부

 

Redis에 잘 캐시되어 있는 것을 볼 수 있습니다.

이제는 RefreshToken을 서버에도 검증을하고 Redis에 넣어두면 굳이 DB를 탈 필요가 없어서 성능이 필요없게 됩니다.

현재 필자는 RefreshToken에 필요한 정보가 없어 해당 토큰 재발급하기 위해선 DB 에서 필요한 정보를 가져옵니다.

 

 

 

 

 

5. 로그아웃

 

로그아웃에는 코드로 보여주는 것이 아닌 글로 설명하겠습니다.

 

토큰 강제 만료

  • 컨트롤러에서 토큰 시간을 0과 Path를 "/"로 하여 강제로 만료 시킵니다.
  • 현재 실행 중인 Thread에서 ThreadLocal에 저장된 SecurityContext 객체를 지웁니다. 
@DeleteMapping("/user/logout")
public ResponseEntity<String> logout(HttpServletResponse response){
    // 시큐리티 context 비우기
    SecurityContextHolder.clearContext();

    // 쿠키 다 삭제
    Cookie accessToken = deleteCookie("accessToken");
    Cookie refreshToken = deleteCookie("refreshToken");

    response.addCookie(accessToken);
    response.addCookie(refreshToken);

    return ResponseEntity.ok().body("Logout success");
}

private Cookie deleteCookie(String cookieName){
    Cookie cookie = new Cookie(cookieName, null);
    cookie.setMaxAge(0);
    cookie.setPath("/");
    return cookie;
}

JWT 블랙리스트

  • 로그 아웃된 JWT를 서버 or Redis or DB에 저장하고 이후에 토큰이 재사용 될 때 인증을 거부하는 방식입니다.
  • 이후에 JwtAuthenticationFilter(Jwt 인증 필터)에서도 이 블랙리스트를 검사하는 로직을 추가하면 더욱 더 안전한 사용이 될 것 같습니다.

 

 

 

 

 

6. 추가적 구상

 

Request에는 많은 정보들을 가지고 있습니다.

 

  • User-Agent
  • IP

이 정보들을 가지고 DB에 저장을 하고 기능을 보안을 구상한다면

 

  1. 새로운 기기에 로그인 했을 시
  2. 다른 지역에서 로그인 했을 시

를 체크할 수 있어서 그런지 사용자에게 더 안전한 사용을 하게 할 수 있습니다.

 

 

감사합니다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함