본문 바로가기

2023년/멋쟁이사자처럼 팀프로젝트

[팀프로젝트] SpringSecurity + Jwt 적용

Spring Security + Jwt 를 프로젝트에 적용 시키기

  • 왜 적용 시켜야 하는가?
  • 어떻게 적용 시키는가?

너무 힘들다.. 중요한건 꺾이지 않는 Security...


1. 왜 적용 시켜야 하는가?

Spring Security 는

Spring 기반의 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크.

Spring Security는 '인증'과 '권한'에 대한 부분을 Filter의 흐름에 따라 처리한다.

Filter이기 때문에 Dispatcher Servlet으로 가기 전에 적용 된다.

출처 : [ https://velog.io/@dailylifecoding/spring-security-authentication-process-flow ]

 

  • 인증 Authentication: 해당 사용자가 본인이 맞는지를 확인하는 절차 ( 나야 나 )
  • 인가 Authorization: 인증된 사용자가 요청한 자원에 접근 가능한지를 결정하는 절차 ( 넌 계급이 뭐니 )

더보기

스프링 시큐리티 가이드에서는 8가지 이유에 대해 스프링 시큐리티의 특징을 적어놓았다.
https://spring.io/projects/spring-security

  • 인증과 인가 모두에 대한 포괄적이고 확장 가능한 지원
  • 세션 고정, 클릭잭킹, 사이트 간 요청 위조 등의 공격으로부터 보호
  • Servlet API 통합
  • Spring Web MVC와의 통합(옵션)
  • 그 밖에도...
  • 즉슨.. 정말 보안이 좋고 간단히 사용할 수 있으니 하는게 좋다! ,, 라는 의미로 느껴짐

2. 어떻게 적용 시키는가?

  • Spring Security 아키텍쳐
    • 처음 이거보고 머리아파서 솔직히 포기하고 싶었지만 대충 전체적인 흐름을 이해해보자면
    • 사용자 접근 > 거름망(필터체인) > 인증, 인가 여부 결정

프로젝트에 구현 해보자

사용환경

IDE : InteliJ

Language : Java

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.7'
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'teamproject'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.projectlombok:lombok:1.18.22'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'


    // Security
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.security:spring-security-test'

    // JWT
    implementation 'io.jsonwebtoken:jjwt:0.9.1'

}

tasks.named('test') {
    useJUnitPlatform()
}

주석으로 처리된 Security 와 Jwt 관련해서 참고하면 됨

 

 

  • 위 3개 클래스 관련해서 주의 깊게 보자

WebSecurityConfig.java

@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
    private final JwtProvider jwtProvider;
    public static final String[] GET_AUTHENTICATED_REGEX_LIST = {
            "^/api/v1/test1$"
    };

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.csrf().disable();
		
        http.cors();

        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        //jwt 사용 간 설정

        http.authorizeHttpRequests()
                .regexMatchers(HttpMethod.GET, GET_AUTHENTICATED_REGEX_LIST).permitAll()

        http.exceptionHandling()
                .authenticationEntryPoint(new CustomAuthenticationEntryPointHandler())
                .accessDeniedHandler(new CustomAccessDeniedHandler());

        http.addFilterBefore(new JwtTokenFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
        http.addFilterBefore(new ExceptionHandlerFilter(), JwtTokenFilter.class);

        return http.build();
    }
}

1. csrf().disable

악용할 수 있는 가능성을 차단하는걸 푸는 기능이지만, rest API 와 토큰을 사용한다면 문제 없어서 품

참고자료 

https://velog.io/@woohobi/Spring-security-csrf%EB%9E%80

 

Spring security - csrf란?

모든 코드는 github에 있습니다.최근에 Spring security를 한창 공부하고 있는데 (조만간 블로그에 security 관련 글이 여러개 올라갈것 같다) 한 가지 궁금하게 한 코드가 있었다.http.csrf().disable()에서 csr

velog.io

2. cors(); 

다른 도메인에서 요청이 가능한가 에 대한 설정이다 .다양한 설정에 관련해서는 속성값을 설정 해줄 수 있음.

https://toycoms.tistory.com/37

참고자료

 

Spring Security CORS

CORS란? - HTTP 요청은 기본적으로 Cross-Site HTTP Requests가 가능합니다. Simple 하게 다른 도메인의 Resource를 사용하는것을 말합니다. 하지만 Cross-Site HTTP Requests는 Same Origin Policy를 적용 받기 때문에 요청

toycoms.tistory.com

 

 

 

Spring Security CORS

CORS란? - HTTP 요청은 기본적으로 Cross-Site HTTP Requests가 가능합니다. Simple 하게 다른 도메인의 Resource를 사용하는것을 말합니다. 하지만 Cross-Site HTTP Requests는 Same Origin Policy를 적용 받기 때문에 요청

toycoms.tistory.com

3. authorizeHttpRequest 와 authorizeRequest 

authorizeHttpRequests(AuthorizationFilter) 가 authorizeRequests(FilterSecurityInterceptor) 를
대체한다 정도로만 정리하겠습니다.

* regex 외에 authorizeRequest 의 autMatcher 를 사용해도 무방함.

 

4. exceptionHandling() 

예외처리 기능이 작동함 - custom 으로 예외를 만들어 처리하게끔 구현함

         .authenticationEntryPoint(authenticationEntryPoint()) - 인증실패시 처리

         .accessDeniedHandler(accessDeniedHandler()) - 인가실패시 처리

 

 

5. addfilterBefore() 

지정된 필터 앞에 커스텀 필터를 추가 (UsernamePasswordAuthenticationFilter 보다 먼저 실행된다)

왜 사용해야될지에 대해서는 해결점이 풀리진 않음  - 알려주세요...!

더보기

에 대한 내용
스프링시큐리티의 기본 인증 처리 담당 필터인 UsernamePasswordAuthenticationFilter 앞에 커스텀 필터를 추가합니다.
UsernamePasswordAuthenticationFilter필터는 Override되지 않고 CustomAuthenticationProcessingFilter 인증 처리가 되면 자연스럽게 로직을 통과하는 구조입니다.
인증로직이 포함된 인증매니저 인증성공/실패 처리 핸들러 등등 기타 추가 설정 이나 기능을 대체 하는 의존성을 주입 할 수 있습니다.

 

JwtProvider.java

@Component
public class JwtProvider {
    private final String SECRET = "asdfknsadfksadnvckasdncksnadkcnklvnzldkknvklxnzsdnnbvklzdfkzfkdnfvk";
    private final long EXPIRATION = 6000 * 10;
    private final String USERNAME_KEY = "username";
    private final String ID_KEY = "id";
    private final String ROLE_KEY = "role";

    public JwtProvider() {
        //secret 과 만기일 재설정
    }

    public String generateToken(User user) {
        Claims claims = Jwts.claims();
        claims.put(ID_KEY, user.getId());
        claims.put(USERNAME_KEY, user.getUsername());
        claims.put(ROLE_KEY, user.getRole().name());

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
                .signWith(SignatureAlgorithm.HS256, SECRET)
                .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                    .setSigningKey(SECRET)
                    .parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.SignatureException | MalformedJwtException exception) { // 잘못된 jwt signature
        } catch (io.jsonwebtoken.ExpiredJwtException exception) { // jwt 만료
        } catch (io.jsonwebtoken.UnsupportedJwtException exception) { // 지원하지 않는 jwt
        } catch (IllegalArgumentException exception) { // 잘못된 jwt 토큰
        }

        return false;
    }

    public Authentication getAuthentication(String token) {
        Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
        Long id = Long.parseLong(claims.get(ID_KEY).toString());
        String username = claims.get(USERNAME_KEY).toString();
        String roleName = claims.get(ROLE_KEY).toString();

        User user = User.builder()
                .id(id)
                .userName(username)
                .role(UserRole.ROLE_USER)
                .build();

        return new UsernamePasswordAuthenticationToken(user, token, user.getAuthorities());
    }
}

1. generateToken : 토큰 생산 - 로그인을 하면 메소드 호출

2. validateToken : 토큰이 유효한가 체크

3. getAuthentication : 암호화된 토큰을 꺼내서 복호화 후 정보 저장 용

 

JwtTokenFilter.java

@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
    /**
     * request 에서 전달받은 Jwt 토큰을 확인
     */

    private final String BEARER = "Bearer ";

    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader(HttpHeaders.AUTHORIZATION);
        request.setAttribute("existsToken", true); // 토큰 존재 여부 초기화
        if (isEmptyToken(token)) request.setAttribute("existsToken", false); // 토큰이 없는 경우 false로 변경

        if (token == null || !token.startsWith(BEARER)) {
            filterChain.doFilter(request, response);
            return;
        }

        token = parseBearer(token);

        if (jwtProvider.validateToken(token)) {
            Authentication authentication = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private boolean isEmptyToken(String token) {
        return token == null || "".equals(token);
    }

    private String parseBearer(String token) {
        return token.substring(BEARER.length());
    }

    /**
     * Security Chain 에서 발생하는 에러 응답 구성
     */
    public static void MakeError(HttpServletResponse response , ErrorCode errorCode) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(errorCode.getHttpStatus().value());
        ObjectMapper objectMapper = new ObjectMapper();

        ErrorResponse errorResponse = new ErrorResponse
                (errorCode, errorCode.getMessage());
        Response<ErrorResponse> resultResponse = Response.error(errorResponse);

        // 한글 출력을 위해 getWriter()
        response.getWriter().write(objectMapper.writeValueAsString(resultResponse));
    }
}

필터 동작과정 관련해서 다음과 같은 자료를 참고해서 이해하자!

https://emgc.tistory.com/125

https://javacan.tistory.com/entry/58

 

[Spring] 스프링 필터의 동작과정

목적 스프링 필터의 동작과정을 이해하기 위함 목차 필터의 동작과정 1. 필터의 동작과정 1. Application의 doFilter() 서버로 요청이 들어오면 StandardWrapperValve 클래스의 invoke메서드가 실행된다. 이 메

emgc.tistory.com

doFilter 는.. FilterChain인터페이스 메소드인데 구현하지 않고도 어떻게 doFilter() 메소드가 동작하는 건지 궁금하다.

 

doFilter 에 대한 인터페이스 메소드인데 구현체 없이 사용할 수 있는가..?

 


 

Custom Error 코드

Custom Error

CustomAuthenticationEntryPointHandler.java

@Slf4j
@Component
public class CustomAuthenticationEntryPointHandler implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);

        // 1. 토큰 없음 2. 시그니처 불일치
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            log.error("토큰이 존재하지 않거나 Bearer로 시작하지 않는 경우");
            ErrorCode errorCode = ErrorCode.INVALID_TOKEN;
            JwtTokenFilter.MakeError(response, errorCode);

        // 3. 토큰 만료
        } else if (authorization.equals(ErrorCode.EXPIRED_TOKEN)) {
            log.error("토큰이 만료된 경우");
            ErrorCode errorCode = ErrorCode.EXPIRED_TOKEN;
            JwtTokenFilter.MakeError(response,errorCode);
        }
    }
}

CustomAccessDeniedHandler.java

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {

        // 4. 토큰 인증 후 권한 거부
        ErrorCode errorCode = ErrorCode.FORBIDDEN_REQUEST;
        JwtTokenFilter.MakeError(response, errorCode);
    }
}

 

ExceptionHandlerFilter.java

@Slf4j
public class ExceptionHandlerFilter extends OncePerRequestFilter {
    /**
     * 토큰 관련 에러 핸들링
     * JwtTokenFilter 에서 발생하는 에러를 핸들링
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        try {
            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException e) {

            //토큰의 유효기간 만료
            log.error("만료된 토큰입니다");
            MakeError(response, ErrorCode.EXPIRED_TOKEN);

        } catch (JwtException | IllegalArgumentException e) {

            //유효하지 않은 토큰
            log.error("유효하지 않은 토큰이 입력되었습니다.");
            MakeError(response, ErrorCode.INVALID_TOKEN);

        } catch (NoSuchElementException e) {

            //사용자 찾을 수 없음
            log.error("사용자를 찾을 수 없습니다.");
            MakeError(response, ErrorCode.USERID_NOT_FOUND);

        } catch (ArrayIndexOutOfBoundsException e) {

            log.error("토큰을 추출할 수 없습니다.");
            MakeError(response, ErrorCode.INVALID_TOKEN);

        } catch (NullPointerException e) {

            filterChain.doFilter(request, response);
        }
    }

}
발견된 이슈.. 팀프로젝트 간 OncePerRequestFilter 가 두번 extends 되고 있었다..

ExceptionHandlerFilter / JwtTokenFilter 이 부분을 좀더 챙겨보고, + doFilter 메소드의 흐름을 이해하면 좀더 좋을거같다는 생각이 듬

 

팀원 도움 덕에 dofilter의 역할을 이해했다.

 

filter Chain 의 dofilter 를 이해할 수 있는 사진

 

WebConfig에서 addFilterBefore 에 매개변수로 넣은 의미는

ExceptionHandlerFilter > JwtTokenFilter > UsernamePasswordAuthenticationFilter => Dispatcher Servlet

각 필터에서 각 로직에 맞게 dofilter로 다음 단계의 필터를 거쳐서 흐름이 생겨 넘겨지는 것으로 이해했다.

 

결론

 

속상하다..

 

 

 

 

참고자료

https://kimchanjung.github.io/programming/2020/07/02/spring-security-02/

 

[Spring Security] 스프링시큐리티 설정값들의 역할과 설정방법(2)

스프링시큐리티의 여러가지 설정값들의 역할과 설정방법을 상세히 알아봅니다. Spring Security 커스텀 필터를 이용한 인증 구현 - 스프링시큐리티 설정(2) 본 포스팅은 스프링시큐리티의 전반적인

kimchanjung.github.io

https://velog.io/@jsang_log/Security-Filter-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-JWT

 

Security Filter 예외처리하기 - JWT

Spring Security에서 토큰 기반 인증 중 예외가 발생한다면 어떤 일이 일어나는지, 어떻게 핸들링 해야하는지에 대해 알아보자.

velog.io