[SpringSecurity] JWT 로그인

HJ·2023년 9월 3일
1

Spring Security

목록 보기
6/9
post-thumbnail

지난 게시글에서 Access Token 과 Refresh Token 에 대해서 알아보았습니다. 이번 게시글에서는 토큰을 이용한 로그인에 대해 정리해보려 합니다. 전체 코드는 GITHUB에서 확인하실 수 있습니다


Token 을 이용한 인증 과정


Access Token 을 통해 사용자를 인증할 수 있습니다. 하지만 보안 상의 이유로 Access Token 은 만료 시간이 짧게 설정되어 있으며, 만료 시간이 짧은 불편함을 해소하기 위해 Refresh Token 이 존재합니다.

클라이언트는 Refresh Token 을 서버에 전달해 Access Token 을 재발급 받습니다. Refresh Token 을 확인하고 Refresh Token 이 유효하다면 ( 만료되지 않았다면 ) Access Token 을 재발급합니다.

유효성 확인 및 Access Token 재발급을 위해 Refresh Token 을 사용자와 매핑하여 DB 에 저장시켜놓아야 하는데 이 때 Redis 를 사용하였습니다.




Redis 를 사용한 이유


세션을 사용하면 메모리에 부하가 걸리게되며, 만약 여러 대의 서버가 정보 공유를 위해 메모리가 아닌 DB 에 저장을 하게 되면 속도가 느려지게 됩니다.

이를 해결하는 것이 바로 Redis 입니다. 데이터를 RAM 에 저장하므로 데이터를 영구적으로 저장할 순 없지만, 빠른 액세스 속도를 보장받을 수 있어 여러 대의 서버가 있어도 정보를 공유할 수 있습니다.

또한 Refresh Token 은 발급된 이후로 일정 시간이 지나면 만료되어야 합니다. Redis 가 아닌 다른 DB 에 저장하게 되면 주기적으로 만료된 토큰을 제거해주어야 하는데 Redis 에서는 데이터의 유효 기간을 지정할 수 있기 때문에 이런 작업이 불필요합니다.




프로젝트 구조


JwtAutheticationFilter

CustomAuthenticationFilter 가 동작하기 전에 거치도록 등록한 필터입니다.

/login 요청이 오면 CustomAuthenticationFilte 로 넘깁니다. 그 외 요청이 오면 Access Token 을 검증하고 SecurityContext 에 저장합니다.

Session 방식을 사용하면 자동으로 저장되지만, Token 을 사용하면서 Session 을 사용하지 않도록 하였기 때문에 수동으로 저장이 필요하며, 이는 권한 처리에 사용됩니다.

이 필터를 거치지 않도록 URL 을 지정할 수 있으며, OncePerRequestFilter 를 상속하였기 때문에 해당 필터는 사용자 요청 한 번 당 딱 한 번만 실행됩니다.



AccessPermissionCheckInterceptor

Controller 로 가기 전, 권한을 확인하기 위해 생성하였습니다.

Controller 에서 @PreAuthorize 를 사용하는 방법도 있지만, Controller 로 가기 전에 처리하는 것이 더 바람직하다고 생각되어 Interceptor 로 구현하게 되었습니다.



CustomAuthenticationFilter

로그인 요청이 들어오면 실행되는 필터입니다. 이 필터에는 나중에 알아볼 로그인 성공 필터, 로그인 실패 필터를 등록할 수 있으며, 어떤 URL 이 들어왔을 때 동작하게 할 것인지 또한 설정할 수 있습니다.

사용자 요청에서 정보를 꺼내 UserPasswordAuthenticationToken 을 만듭니다. 이때 토큰은 아직 사용자 인증이 완료되지 않은 토큰입니다.



AuthenticationManager

CustomAuthenticationFilter 로부터 인증 전 AuthenticationToken 을 전달받아, Provider 에게 인증을 수행하도록 다시 전달합니다.



CustomAuthenticationProvider

AuthenticationToken 에 담긴 id 를 꺼내 UserDetilsServiceImpl 의 loadUserByUsername() 에 전달합니다. 이 결과는 UserDetailsImpl 을 반환합니다.

이를 이용하여 인증이 완료된 UsernamePasswordAuthenticationToken 을 생성하여 반환합니다.



CustomLoginSuccessHandler

인증이 성공하면 CustomAuthenticationFilter 에 등록한 로그인 성공 핸들러가 실행됩니다. Access Token 과 Refresh Token 을 생성하며, Refresh Token 은 Redis 에 저장하고, 클라이언트에게 두 개의 토큰 모두를 전달합니다.




Token 생성 및 Redis 에 저장


[ LoginSuccessHandler ]

@RequiredArgsConstructor
public class CustomLoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    private final JwtService jwtService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        // 인증 성공 후 처리되는 핸들러이기 때문에 전달받은 authentication 은 인증이 완료된 객체
        Member member = ((UserDetailsImpl) authentication.getPrincipal()).getMember();

        // 인증이 성공한 경우 토큰을 생성하여, 응답 헤더에 담아 클라이언트에게 전달
        String accessToken = JwtUtils.generateAccessToken(member);
        String refreshToken = JwtUtils.generateRefreshToken(member);

        // 인증이 성공했으니 Refresh Token 을 DB( Redis )에 저장한다
        jwtService.save(new RefreshToken(refreshToken, member.getId()));

        // 헤더로 accessToken 전달
        response.addHeader(JwtConstants.ACCESS, JwtConstants.JWT_TYPE + accessToken);
        // Refresh Token 은 Cookie 에 담아서 전달하되, XSS 공격 방어를 위해 HttpOnly 를 설정한다
        Cookie cookie = new Cookie(JwtConstants.REFRESH, refreshToken);
        cookie.setMaxAge((int) (JwtConstants.REFRESH_EXP_TIME / 1000));     // 5분 설정
        cookie.setHttpOnly(true);
        response.addCookie(cookie);
    }
}

로그인이 성공했을 때 수행되는 Handler 이며, 로그인이 끝났기 때문에 이곳에서 Access Token 과 Refresh Token 을 생성하고 DB 에 저장 및 클라이언트에게 반환합니다.

토큰을 생성할 때는 JwtUtilsgenerateXXXToken() 메서드를 호출하는데 이 때 인증이 완료된 Authentication 내부의 Member 객체를 꺼내 넘겨주게 됩니다.

JwtUtils 에서 Access Token 을 생성할 때는 Claim 에 memberId 와 role 에 대한 정보를 담는 반면, RefreshToken 을 만들 때는 들어가지 않도록 하였습니다.

Redis 에 Refresh Token 을 저장할 때는 token 과 memberId 를 가진 RefreshToken 이라는 객체를 만들어 전달하였습니다.



[ JwtRepository ]

@Repository
@RequiredArgsConstructor
public class JwtRepository {

    private final RedisTemplate redisTemplate;

    public RefreshToken save(RefreshToken refreshToken) {
        ValueOperations valueOperations = redisTemplate.opsForValue();
        valueOperations.set(refreshToken.getToken(), refreshToken.getMemberId());
        redisTemplate.expire(refreshToken.getToken(), JwtConstants.REFRESH_EXP_TIME, TimeUnit.MILLISECONDS); // 5분 동안 Redis 에 저장
        return refreshToken;
    }

    ...
}

RedisTemplate 을 이용해 String, List, Set 등을 Redis 에 저장할 수 있는데, token 과 memberId 모두 String 을 사용하기 때문에 opsForValue() 를 사용하였습니다.

RedisTemplate 의 opsForXXX() 메서드들은 특정 컬렉션의 커맨드를 호출할 수 있는 기능들을 모아둔 XXXOperations 를 반환합니다.

set() 메서드를 통해 Redis 에 RefreshToken 객체 내부에 담긴 token 과 memberId 를 저장하고, expire() 메서드를 통해 token 이 저장되는 만료 시간을 지정합니다.




Token 검증


@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final String[] whitelist = {"/", "/signUp", "/login", "/renew", "/js/**", "/loginPage"};

    // 필터를 거치지 않을 URL 을 설정하고, true 를 return 하면 바로 다음 필터를 진행하게 됨
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String requestURI = request.getRequestURI();
        return PatternMatchUtils.simpleMatch(whitelist, requestURI);
    }

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

        String header = request.getHeader(JwtConstants.JWT_HEADER);

        // 토큰이 없거나 정상적이지 않은 경우
        if (header == null || !header.startsWith(JwtConstants.JWT_TYPE)) {
            response.setStatus(SC_BAD_REQUEST);
            response.setContentType(APPLICATION_JSON_VALUE);
            response.setCharacterEncoding("utf-8");
            new ObjectMapper().writeValue(response.getWriter(), "Token 이 존재하지 않습니다");
            return;
        }

        try {
            // 토큰 검증
            String token = JwtUtils.getTokenFromHeader(header);
            DecodedJWT decodedJWT = verifyToken(token);

            // SecurityContext 에 authenticationToken 저장
            UsernamePasswordAuthenticationToken authenticationToken = JwtUtils.getAuthenticationToken(decodedJWT);
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);

            // 이거 없으면 다음 실행 안됨!!
            doFilter(request, response, filterChain);

        } catch (TokenExpiredException e) {
            // 토큰 만료 시 발생하는 예외
            response.setStatus(SC_UNAUTHORIZED);
            response.setContentType(APPLICATION_JSON_VALUE);
            response.setCharacterEncoding("utf-8");
            new ObjectMapper().writeValue(response.getWriter(), "Access Token 이 만료되었습니다.");
        } catch (Exception e) {
            response.setStatus(SC_BAD_REQUEST);
            response.setContentType(APPLICATION_JSON_VALUE);
            response.setCharacterEncoding("utf-8");
            new ObjectMapper().writeValue(response.getWriter(), "올바르지 않은 Token 입니다.");
        }
    }
}

사용자의 요청이 들어왔을 때 먼저 실행되는 필터입니다. 이 필터를 거치지 않을 URI 를 지정하여 shouldNotFilter() 를 통해 바로 다음 필터를 진행시킬 수 있습니다.

이 필터에서는 토큰에 대한 검증을 수행하며 JwtUtils 의 verifyToken() 을 사용하여 검증을 수행합니다. 내부에서는 JWTVerifier 를 사용하는데 이는 주어진 토큰이 적절한 JWT 형식일 뿐만 아니라 서명도 일치하는지 확인하는 verify() 메서드를 가지고 있습니다.

검증이 완료되면 디코딩된 DecodedJWT 를 반환합니다. 이 토큰 내부에는 토큰 생성 시 추가한 id 와 role 을 가지고 있는데 이를 이용하여 UsernamePasswordAuthenticationToken 를 생성하고, SecurityContext 에 저장합니다.

이렇게 저장된 UsernamePasswordAuthenticationToken 를 보고 Interceptor 에서 권한 처리를 진행합니다.




권한에 따른 처리


public class AccessPermissionCheckInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (request.getRequestURI().startsWith("/admin")) {
            if (authentication.getAuthorities().contains(new SimpleGrantedAuthority(MemberRole.ADMIN.getValue()))) {
                return true;
            }
        } else {
            if (authentication.getAuthorities().contains(new SimpleGrantedAuthority(MemberRole.USER.getValue()))) {
                return true;
            }
        }
        response.setStatus(SC_FORBIDDEN);
        response.setCharacterEncoding("utf-8");
        new ObjectMapper().writeValue(response.getWriter(), "접근 권한이 없습니다.");
        return false;
    }
}

권한에 따른 처리를 하기 위한 Interceptor 이며, Interceptor 의 preHandle() 은 DispatcherServlet 이후, Controller 이전에 호출됩니다.

Interceptor 를 등록하고, Interceptor 를 호출할 URL 을 지정하는 것은 WebConfig 에서 진행합니다. 저는 excludePathPatterns 를 이용하여 검사하지 않을 URI 만 지정하였습니다.

권한 검사를 진행할 때는 SecurityContext 에 저장된 Authentication 객체를 사용합니다. 그 후 authentication 의 권한들 확인해 권한이 올바르다면 계속 진행하도록 하고, 그렇지 않으면 403 상태코드와 함께 접근 권한이 없다는 메세지를 작성합니다.




Refresh Token 으로 Access Token 재발급


[ JwtController ]

@RestController
@RequiredArgsConstructor
public class JwtController {

    private final JwtService jwtService;

    @GetMapping("/renew")
    public ResponseEntity<?> renewToken(HttpServletRequest request, HttpServletResponse response) {
        try {
            String refreshToken = JwtUtils.getTokenFromHeader(request.getHeader(JwtConstants.JWT_HEADER));
            return ResponseEntity.ok(JwtConstants.JWT_TYPE + jwtService.renewToken(refreshToken));
        } catch (NoSuchElementException e) {
            return ResponseEntity.badRequest().body("Refresh Token 이 만료되었습니다");
        } catch (UsernameNotFoundException e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }
}

request 에서 토큰이 저장된 헤더 정보를 가져와 토큰만 추출한 다음, jwtService 의 renewToken() 메서드를 호출하여, Access Token 을 재발급 받습니다.

이 과정에서 Redis 에 Refresh Token 이 없다면 NoSuchElementException 이 발생하고 이는 해당 토큰은 만료된 것입니다.



[ JwtService ]

@Service
@RequiredArgsConstructor
public class JwtService {

    private final JwtRepository jwtRepository;
    private final MemberRepository memberRepository;

    ...

    public Optional<RefreshToken> findByToken(String token) {
        return jwtRepository.findByToken(token);
    }

    public String renewToken(String refreshToken) {
        // token 이 존재하는지 찾고, 존재한다면 RefreshToken 안의 memberId 를 가져와서 member 를 찾은 후 AccessToken 생성
        RefreshToken token = this.findByToken(refreshToken).orElseThrow(NoSuchElementException::new);
        Member member = memberRepository.findById(token.getMemberId()).orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다"));
        return JwtUtils.generateAccessToken(member);
    }
}

주어진 token 을 TokenRepository 에 전달하여, Redis 에 토큰이 있는지 확인합니다. 토큰이 있다면 RefreshToken 이 반환되지만 없다면 NoSuchElementException 이 반환됩니다.

Token 을 만들기 위해서는 Member 객체가 필요하기 때문에 TokenRepository 를 통해 RefreshToken 에 존재하는 MemberId 를 통해 MemberRepository 에서 Member 를 조회합니다.

이렇게 찾아진 Member 객체를 TokenUtils 에 전달하여 토큰을 만들고, 이를 반환합니다.



[ JwtRepository ]

@Repository
@RequiredArgsConstructor
public class JwtRepository {

    private final RedisTemplate redisTemplate;

    ...

    public Optional<RefreshToken> findByToken(String refreshToken) {
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        String userId = valueOperations.get(refreshToken);

        if (Objects.isNull(userId)) {
            return Optional.empty();
        }
        return Optional.of(new RefreshToken(refreshToken, userId));
    }
}

0개의 댓글