회원 도메인 개발과 JWT 인증/인가 처리

Spring Security, JWT

인증은 당신이 누구냐에 대한 것이다. 인가는 당신이 내 집에서 할 수 있는 것들, 즉 사용할 수 있는 자원을 정의한다. Spring Security는 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크이다.

이번 캡스톤디자인 프로젝트에서 만들게 될 플랫폼 서비스는 기본적으로 회원 도메인이 들어가는 서비스이기에 이에 대한 인증/인가 처리를 유연하게 할 수 있도록 Spring Security를 활용하고자 하였다.

또한 Frontend와 Backend가 분리된 구조로 팀 프로젝트를 진행하기 때문에, 다양한 방법 중 JWT Token 인증/인가 방식으로 회원가입/로그인 로직을 구현하였다.
// JWT 방식에 대한 자세한 사항은 다른 포스팅으로 게시할 예정


// 이미지 출처: jwt.io

구현했던 주요 로직은 다음과 같다.

  1. Email(ID)/Password 로그인 시도
  2. Email(ID)/Password 검증 후 Access Token, Refresh Token 발급
  3. API 요청 시 HTTP Header Authorization에 Access Token을 담아 요청
  4. API 응답 또는 Access Token 만료 응답 보내줌
  5. Access Token 만료 시 Request Body에 Access Token, Refresh Token을 담아 재발급 요청
  6. 토큰 검증 후 새로운 Access Token, Refresh Token을 발급

Backend 패키지 구조

전체적인 패키지 구조는 다음과 같다. 현재는 member 도메인과 모든 도메인에 공통적으로 들어갈 전체적인 뼈대만 작업했기 때문에 요구사항 변경 시 내부 내용물들은 바뀔 수도 있다.

WaitForm
├── src       
│   ├── domain  # 각 도메인의 내부 구조는 같다.
│        ├── member
│            ├── controller
│            ├── service
│            ├── repository
│            ├── exception
│            ├── entity
│            └── dto
│        ├── order
│        ├── chatting
│        └── like
│   └── global
│        ├── config
│             ├── jwt  # JWT 설정 클래스들
│             └── SecurityConfig.java, 기타 설정 클래스
│        ├── error  # 전역적인 Exception Handling을 위한 클래스들
│        └── result # 응답 데이터 통합을 위한 클래스들
│   └── test  # Test Code
└──

JWT와 Security 설정

JWT를 적용하기 위한 큰 틀은 이 방법이 무조건 정답은 아니겠지만, 구글링했던 결과들을 종합하면 가장 나은 방법이라 생각했었다. 세부적인 코드는 크게 공개하지 않고 각 클래스들이 어떤 역할을 하는지만 언급하고 넘어가겠다.
// 정은구님의 Inflearn 강의 Spring Boot JWT Tutorial를 참고하자.

JWT 관련

  • TokenProvider: 유저 정보로 JWT 토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져온다.
  • JwtFilter: Spring Request 앞단에 붙일 Custom Filter

Spring Security 관련

  • JwtSecurityConfig: JWT Filter를 추가
  • JwtAccessDeniedHandler: 접근 권한 없을 때 403 에러
  • JwtAuthenticationEntryPoint: 인증 정보 없을 때 401 에러
  • SecurityConfig: 스프링 시큐리티에 필요한 설정
  • SecurityUtil: SecurityContext에서 전역으로 유저 정보를 제공하는 유틸 클래스

이 중 TokenProvider, SecurityConfig만 요약하자면 다음과 같다.

TokenProvider

JWT Token에 관련된 암호화, 복호화, 검증 로직은 모두 이 클래스에서 이루어진다.

  • generateTokenDto
    • 유저 정보를 넘겨받아 Access Token과 Refresh Token을 생성
    • 넘겨받은 유저 정보의 authentication.getName()이 username을 가져온다.
    • Access Token에는 유저와 권한 정보를 담고 Refresh Token에는 아무 정보도 담지 않는다.
  • getAuthentication
    • JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼낸다.
    • Access Token에만 유저 정보를 담기에 accessToken을 파리미터로
    • Refresh Token에는 만료일자만 담는다.
  • validateToken
    • 토큰 정보를 검증
    • Jwts 모듈이 알아서 Exception을 던져준다.

SecurityConfig

package me.ramos.WaitForm.global.config;

import lombok.RequiredArgsConstructor;
import me.ramos.WaitForm.global.config.jwt.JwtAccessDeniedHandler;
import me.ramos.WaitForm.global.config.jwt.JwtAuthenticationEntryPoint;
import me.ramos.WaitForm.global.config.jwt.JwtSecurityConfig;
import me.ramos.WaitForm.global.config.jwt.TokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    // Swagger 3.x 설정
    private static final String[] PERMIT_URL_ARRAY = {
            /* swagger v2 */
            "/v2/api-docs",
            "/swagger-resources",
            "/swagger-resources/**",
            "/configuration/ui",
            "/configuration/security",
            "/swagger-ui.html",
            "/webjars/**",
            /* swagger v3 */
            "/v3/api-docs/**",
            "/swagger-ui/**"
    };

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // CORS 설정
    @Bean
    public CorsConfigurationSource configurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.addAllowedOriginPattern("*");
        configuration.addAllowedHeader("*");
        configuration.addAllowedMethod("*");
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);

        return source;
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web
                .ignoring()
                .antMatchers("/h2-console/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        http
                .logout()
                .disable();

        http
                .csrf().disable()

                // exceptionHandling customizing
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)

                .and()
                .authorizeRequests()
                .antMatchers("/", "/auth/login", "/auth/signup").permitAll()
                .antMatchers(PERMIT_URL_ARRAY).permitAll()
                .anyRequest().authenticated()

                // JwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig를 등록
                .and()
                .apply(new JwtSecurityConfig(tokenProvider));
    }
}

Swagger 설정과, 몇 가지 커스터마이징 할 요소들이 있어 시큐리티 필터에서 제외해야 하거나 기타 설정이 필요한 내용들을 위와 같이 작업하였다.

Postman으로 JWT 로직 테스트

회원가입

Frontend에서 사용자가 입력한 email, password, nickname 값을 JSON 형식으로 요청하면 다음과 같이 정상 응답을 반환한다.

로그인

Frontend에서 사용자가 입력한 email, password 값을 JSON 형식으로 요청하면 다음과 같이 accessToken과 refreshToken과 함께 정상 응답을 반환한다.

테스트로 사용중인 H2 Database 내부를 보면 회원의 password는 BCryptPasswordEncoder에 의해 암호화되어 저장되어있고, Refresh Token 역시 정상적으로 DB에 저장되어 있다.

다만, Access Token의 유효 기간과 이에 대한 재발급을 위한 용도이고, 현재 아키텍쳐 구조상 DB에서 불러오는 I/O가 성능 이슈를 발생할 여지가 있기 때문에 추 후 캐시 역할을 할 Redis로 토큰 저장소를 변경하고자 한다.

토큰 재발급

토큰 재발급의 경우, HTTP Header Authorization에 Bearer {accessToken}으로 셋팅된 상태로 accessToken, refreshToken 값을 서버로 전송해야 한다.

정상 요청의 경우 다음과 같이 두 토큰이 재발급 된다.

다음 포스팅에선?

현재 Postman에서 보이는 Response Body의 값들을 보면, 응답 값들만 나타나있고 상태코드나 메시지에 대한 내용은 전혀 없으며 또한 에러 발생 시 이를 핸들링해서 일관된 형식으로 나타낼 수 없는 구조이다.

이에 대한 처리를 한 과정들을 다음 포스팅에 게시할 예정이다.

References

좋은 웹페이지 즐겨찾기