내배캠 주요 학습/TIL : Today I Learned

JWT HTTP Method 확인 방법

chaeyoung- 2023. 6. 28. 11:32

 

# 문제점 :

로그인 url에 POST를 제외한 다른 Method로 요청이 들어와서 로그인 처리가 된다는 문제점이 도출되었습니다.

GET, DELETE, PUT 등의 요청이 들어오면 로그인 처리가 되지 않도록 거부해야 합니다.

 

 

 

 

현재 로그인 처리는 아래 클래스에서 수행합니다. (JwtAuthenticationFilter.java)

UsernamePasswordAuthenticationFilter를 상속받았습니다.

 

 


 

<< JwtAuthenticationFilter >>

 

  • JwtUtil 타입의 객체인 jwtUtil을 멤버 변수로 갖습니다.
  • JwtAuthenticationFilter 클래스는 아래와 같은 생성자 메서드를 갖습니다.
    public JwtAuthenticationFilter(JwtUtil jwtUtil) {   
        this.jwtUtil = jwtUtil;
        setFilterProcessesUrl("/api/user/login");
    }

 

 

  • UsernamePasswordAuthenticationFilter 클래스의 메서드들을 오버라이딩 했습니다.
    • attemptAuthentication() , 
      successfulAuthentication() ,
      unsuccessfulAuthentication()

 

  • Client에 상태코드와 메시지를 반환하기 위해 HTTPServletResponse 객체에 Setting하는 메서드 생성
    private void responseResult(HttpServletResponse response, int statusCode, String message) throws IOException {
        String jsonResponse = "{\"status\": " + statusCode + ", \"message\": \"" + message + "\"}";

        // Content-Type 및 문자 인코딩 설정
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        // PrintWriter 를 사용하여 응답 데이터 전송
        PrintWriter writer = response.getWriter();
        writer.write(jsonResponse);
        writer.flush();
    }

 

 

 

 

#시도 :

 

 

WebSecurityConfig.java  내에 @Bean이 달려있는 SecurityFilterChain() 메서드 내부 수정.

 

http.authorizeHttpRequests((authorizeHttpRequests) ->
        authorizeHttpRequests
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
                .requestMatchers("/").permitAll() // 메인 페이지 요청 허가
                .requestMatchers("/api/user/signup").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가
                .requestMatchers("/api/posts/**").permitAll() // '/api/posts/'로 시작하는 요청 모두 접근 허가
                .requestMatchers("/api/post/**").permitAll() // '/api/post/'로 시작하는 요청 모두 접근 허가
                .anyRequest().authenticated() // 그 외 모든 요청 인증처리
);

 

.requestMatchers()에서 Method를 확인해보려고 시도해보았습니다.

그러나, Method 확인이 한개씩 밖에 되지 않아서, Method 별로 모두 접근 거부를 해줘야 했습니다.

 

 

 

 

아래 코드와 같이 하게되면 코드가 길어진다는 단점이 있습니다.

개발자는 코드를 최대한 효율적으로 쓰는 것이 좋다 생각하여 다른 방법을 생각해보았습니다.

 

           .requestMatchers(HttpMethod.GET,"/api/user/login").denyAll()
           .requestMatchers(HttpMethod.DELETE,"/api/user/login").denyAll()
           .requestMatchers(HttpMethod.PATCH,"/api/user/login").denyAll()
           .requestMatchers(HttpMethod.PUT,"/api/user/login").denyAll()

 

 

 

 

 

그래서 HTTP Method를 배열로 넘겨줄 수는 없을까 고민해보았습니다.

먼저, requestMatchers 구현체를 살펴보다가, HttpMethod 구현체도 같이 살펴보았습니다.

 

 

requestMatchers
HttpMethod 구현체 내부

 

 

 

 

HttpMethod 구현체 내부에서 아래의 static 메서드를 발견하였습니다.

	public static HttpMethod[] values() {
		HttpMethod[] copy = new HttpMethod[values.length];
		System.arraycopy(values, 0, copy, 0, values.length);
		return copy;
	}

 

 

 

 

WebSecurityConfig.class 내부에 배열을 선언해서 사용해보자 생각했습니다.

아래와 같이 POST 메서드는 제외하고 배열을 생성했습니다.

 

HttpMethod[] values = new HttpMethod[] { HttpMethod.GET, HttpMethod.HEAD,
                HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE };

 

 

 

 

아래와 같이 접근 제한을 걸어봤지만, 의도한 대로 실행이 되지 않았습니다.

 

.requestMatchers(Arrays.toString(values),"/api/user/login").denyAll()

 

 

 

 

 

전체코드 

더보기
        HttpMethod[] values = new HttpMethod[] { HttpMethod.GET, HttpMethod.HEAD,
                HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.DELETE, HttpMethod.OPTIONS, HttpMethod.TRACE };


        http.authorizeHttpRequests((authorizeHttpRequests) ->
                authorizeHttpRequests
                        .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
                        .requestMatchers("/").permitAll() // 메인 페이지 요청 허가
                        .requestMatchers("/api/user/signup").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가
                        .requestMatchers("/api/posts/**").permitAll() // '/api/posts/'로 시작하는 요청 모두 접근 허가
                        .requestMatchers("/api/post/**").permitAll() // '/api/post/'로 시작하는 요청 모두 접근 허가
                        .requestMatchers(Arrays.toString(values),"/api/user/login").denyAll()
                        .anyRequest().authenticated() // 그 외 모든 요청 인증처리
        );

 

 

그러나, 의도한 대로 실행이 되지 않았습니다.

HttpMethod 구현체 내부에 있는 values 메서드는 이때 활용하는 것이 아닌것 같았다..

 

 

 

 

 

 

# 해결 방법

 

 

JwtAuthenticationFilter 내부에 attemptAuthentication() 메소드에서 메서드를 판별하면 됩니다.

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (!request.getMethod().equals(HttpMethod.POST.name())) {
            // 해당 url 로 들어온 요청의 Method 가 POST 가 아니라면
            try {
                responseResult(response,400,"HTTP Method Error");
                return null;
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
-- 중략 --
}

 

클라이언트로 부터 들어온 요청을 HttpServletRequest request에 받습니다.

해당 request 객체의 메서드가 POST인지 아닌지 판별합니다.

POST일 경우, response에 상태코드와 응답 메시지를 setting 한 후에 return null을 하여

해당 메서드를 종료 시킬 수 있습니다.

 

 

그렇게 되면 로그인 요청은 POST 메서드일 경우에만 실행됩니다.

 

 

 

 

 

결과 : 

Post Method
Get Method

 

 

 

 

 

 

전체 코드는 다음과 같습니다.

 


 

 

 

JwtAuthenticationFilter.java

package com.sparta.myblog.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.myblog.dto.UserRequestDto;
import com.sparta.myblog.entity.UserRoleEnum;
import com.sparta.myblog.jwt.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.io.IOException;
import java.io.PrintWriter;

@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {   
        this.jwtUtil = jwtUtil;
        setFilterProcessesUrl("/api/user/login");
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (!request.getMethod().equals(HttpMethod.POST.name())) {
            // 해당 url 로 들어온 요청의 Method 가 POST 가 아니라면
            try {
                responseResult(response,400,"HTTP Method Error");
                return null;
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

        try {
            UserRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), UserRequestDto.class);

            return getAuthenticationManager().authenticate(
                    new UsernamePasswordAuthenticationToken(
                            requestDto.getUsername(),
                            requestDto.getPassword(),
                            null
                    )
            );
        } catch (IOException e) {
            log.error(e.getMessage());
            throw new RuntimeException(e.getMessage());
        }
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException {
        String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
        UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();

        String token = jwtUtil.createToken(username, role);
        response.addHeader(JwtUtil.AUTHORIZATION_HEADER, token);

        // 응답 상태 코드와 메시지 설정
        responseResult(response, 200, "로그인 성공");
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
        responseResult(response, 400, "회원을 찾을 수 없습니다.");
    }


    // Client 에 반환할 msg, status 세팅 메서드
    private void responseResult(HttpServletResponse response, int statusCode, String message) throws IOException {
        String jsonResponse = "{\"status\": " + statusCode + ", \"message\": \"" + message + "\"}";

        // Content-Type 및 문자 인코딩 설정
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        // PrintWriter 를 사용하여 응답 데이터 전송
        PrintWriter writer = response.getWriter();
        writer.write(jsonResponse);
        writer.flush();
    }
}

 

 

JwtUtil.java

package com.sparta.myblog.jwt;

import com.sparta.myblog.entity.UserRoleEnum;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.security.Key;
import java.util.Base64;
import java.util.Date;

@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil {
    // Header KEY 값
    public static final String AUTHORIZATION_HEADER = "Authorization";
    // 사용자 권한 값의 KEY
    public static final String AUTHORIZATION_KEY = "auth";
    // Token 식별자
    public static final String BEARER_PREFIX = "Bearer ";
    // 토큰 만료시간
    private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분

    @Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    // 토큰 생성
    public String createToken(String username, UserRoleEnum role) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(username) // 사용자 식별자값(ID)
                        .claim(AUTHORIZATION_KEY, role) // 사용자 권한
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    // header 에서 JWT 가져오기
    public String getJwtFromHeader(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException | SignatureException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

    // 토큰에서 사용자 정보 가져오기
    public Claims getUserInfoFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }
}