개인 프로젝트

JWT Token + Spring Security

오마이냥 2024. 7. 27. 14:19

현재 프로젝트는 0.11.5 버전 사용

강의는 0.12.3 버전 사용

dependencies {
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}

 

 

 

기본적인 SecurityConfig 클래스 

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            .csrf((auth) -> auth.disable());

    http
            .formLogin((auth) -> auth.disable());

    http
            .httpBasic((auth) -> auth.disable());

    http
            .authorizeHttpRequests((auth) -> auth
                    .requestMatchers("/", "/**").permitAll() //임시 설정
                    .anyRequest().authenticated());

    http
            .sessionManagement((session) -> session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

    return http.build();
}

 

csrf를 disable() 하는 이유는?

session 방식에서는 session이 항상 고정되기 때문에 csrf 공격에 대해 필수적으로 방어해야 하지

JWT 방식은 session을 STATELESS 상태로 관리하기 때문에 csrf 공격을 방어하지 않아도 됨

 

JWT 방식으로 로그인을 진행할 것이기 때문에 

formLogin 방식과 httpBasic 방식을 disable 해준다.

 

모든 권한을 허용하는 permitAll()

나머지 Request에 대해서는 Authenticated() 즉 로그인 한 사용자만 접근할 수 있음

 

JWT 상태에서는 session을 STATELESS 상태로 관리한다.

 

Security를 통해서 회원 정보를 저장하고 회원 가입 및 검증할 땐 항상 비밀번호릘 cache로 암호화 시켜서 검증하고 진행하기 때문에 상단에 BCryptPasswordEncoder를 Bean으로 등록해 암호화를 진행하는 데 사용

 

 

Password 암호화

bCryptPasswordEncoder.encode(dto.getPassword())

 

SecurityConfig에 BCryptPasswordEncoder를 Bean으로 등록 후 

SignUpController에서 Member Entity에 Password를 암호화하여 담는다!! -> DB에서 확인함

 

 

일반 session 방식으로 로그인을 진행할 경우 

UsernamePasswordAuthenticationFilter, AuthenticationManager를 만들지 않아도 Spring 쪽에서 기본 default 값으로 처리해주었는데 

이번에 JWT 방식으로 할 땐 formLogin을 disable 했기 때문에 UsernamePasswordAuthenticationFilter, AuthenticationManager를 커스텀해서 구현해줘야한다.

 

어떻게?

Email, Password가 들어오면 UsernamePasswordAuthenticationFilter가 Email, Password를 꺼내서 로그인을 진행한다고 AuthenticationManager에게 넘겨준다.

AuthenticationManager가 DB로부터 회원정보를 가져와서 검증을 진행하고 검증 확인이 되면

successfulAuthentication이 동작된다.  

successfulAuthentication이 동작할 때 JWT를 생성해서 사용자한테 응답해줘도 되고

검증이 실패하면 unsuccessful이 실행되어야 하는데 이는 JWT를 만들지 않고 401 응답코드를 return한다.

 

 

클라이언트 요청 → 서블릿 필터 → 서블릿 (컨트롤러)

스프링 시큐리티는 클라이언트의 요청이 여러개의 필터를 거쳐 DispatcherServlet(Controller)으로 향하는 중간 필터에서 요청을 가로챈 후 검증(인증/인가)을 진행한다.

 

 

 

서블릿 필터 체인의 DelegationFIlter -> Security 필터 체인 (내부 처리 후) -> 서블릿 필터 체인의 DelegationFilter

Spring Security 의존성을 추가하면 서블릿 필터에서 DelegationFIlterProxy를 등록해서 모든 요청을 가로챈다. 가로챈 요청은 SecurityFilterChain에서 처리 후 상황에 따른 거부, 리디렉션, 서블릿으로 요청 전달을 진행한다.

 

좌측은 Servlet Filter..............................우측은 Security Filter....................................

 

Form 로그인 방식에서는 클라이언트단이 username과 password를 전송한 뒤 Security 필터를 통과하는데 UsernamePasswordAuthentication 필터에서 회원 검증을 진행을 시작한다.
(회원 검증의 경우 UsernamePasswordAuthenticationFilter가 호출한 AuthenticationManager를 통해 진행하며 DB에서 조회한 데이터를 UserDetailsService를 통해 받음)

하지만 JWT 프로젝트는 SecurityConfig에서 formLogin 방식을 disable 했기 때문에 기본적으로 활성화 되어 있는 해당 필터는 동작하지 않는다. 따라서 로그인을 진행하기 위해서 필터를 커스텀하여 등록해야 한다.

 

 

로그인 요청 받기 : 커스텀 UsernamePasswordAuthentication  필터 작성

UsernamePasswordAuthenticationFIlter를 상속받은 LoginFIlter 클래스를 구현해보자.

 

public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    public LoginFilter(AuthenticationManager authenticationManager) {

        this.authenticationManager = authenticationManager;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

				//클라이언트 요청에서 username, password 추출
        String username = obtainUsername(request); //★email은 request.getParameter 방식으로 해보자??
        String password = obtainPassword(request);

				//스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함
				//★바구니(DTO) 역할
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);

				//★token에 담은 검증을 위한 AuthenticationManager로 전달
        return authenticationManager.authenticate(authToken);
    }

		//로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨)
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {

    }

		//로그인 실패시 실행하는 메소드
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {

    }
}

 

 

이렇게 만든 필터를 사용하기 위해선 SecurityConfig에 등록해야 한다.

 

SecurityConfig : 커스텀 로그인 필터 등록

//필터 추가 LoginFilter()는 인자를 받음 (AuthenticationManager() 메소드에 authenticationConfiguration 객체를 넣어야 함) 따라서 등록 필요
http
        .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class);
        
        //필터 추가 LoginFilter()는 인자를 받음 (AuthenticationManager() 메소드에 authenticationConfiguration 객체를 넣어야 함) 따라서 등록 필요

 

UsernamePasswordAuthenticationFilter를 대체해서 커스텀 로그인 필터를 넣을 것이기 때문에 At이라는 메서드를 사용한다.

(첫 번째 인자 : 커스텀 로그인 필터, 두 번째 인자 : 등록 위치)

 

SecurityConfig : AuthenticationManager Bean 등록과 LoginFilter 인수 전달

로그인 필터는 어떤 특정한 인자를 받는다. 클래스를 만들 때 생성자 방식으로 AuthenticationManager를 주입 받았는데  SecurityConfig에서 주입을 시켜두지 않으면  동작을 안하기 때문에 AuthenticationManager를 등록해서 SecurityConfig에 주입하도록 하자.

		//AuthenticationManager Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {

        return configuration.getAuthenticationManager();
    }

 

 

AuthenticationManager 또한 AuthenticationConfiguration이라는 인자를 받는다. 그러므로  AuthenticationManager가 인자로 받을 AuthenticationConfiguration 객체 생성자를 주입시키자.

private final AuthenticationConfiguration authenticationConfiguration;

 

 

DB 기반 로그인 검증 로직 : CustomUserDetails(DTO) 생성

CustomUserDetails는 데이터를 넘겨주는 DTO 역할을 할 것이다.

 

CustomUserDetails는 UserDetails를 상속받는데 UserDetails란 Spring Security에서 사용자 정보를 담는 인터페이스로 Spring Security에서 사용자의 정보를 불러오기 위해서 구현해야 하는 인터페이스로 기본 오버라이드 메서드들이 존재한다. (getAuthorities(), getPassword(), getUsername(), isAccountNonExpired() 등등)

 

 

JWT 발급 및 검증 클래스

로그인시 -> 성공 -> JWT 발급

접근시 -> JWT 검증

 

JWT에 관해 발급과 검증을 담당할 클래스가 필요하다. 따라서 JWTUtil이라는 클래스를 생성하여 JWT 발급, 검증 메서드를 만들 것이다.

 

JWT는 Header.Payload.Signature 구조로 구분을 해서 내부의 데이터를 전달한다. 

Header : JWT임을 명시, 사용된 암호화 알고리즘 명시 <- 간단한 토큰에 대한 정보

Payload: 실제로 사용자가 넣어둔 정보(USername, Role, 토큰 발급일자 등등)

Signature: 암호화알고리즘((BASE64(Header))+(BASE64(Payload)) + 암호화키)

 

JWT의 특징은 내부 정보를 단순 BASE64 방식으로 인코딩하기 때문에 외부에서 쉽게 디코딩 할 수 있다.

외부에서 열람해도 되는 정보를 담아야하며, 토큰 자체의 발급처를 확인하기 위해서 사용한다.

(지폐와 같이 외부에서 그 금액을 확인하고 금방 외형을 따라서 만들 수 있지만 발급처에 대한 보장 및 검증은 확실하게 해야하는 경우에 사용한다. 따라서 토큰 내부에 비밀번호와 같은 값 입력 금지)

 

HS256: 양방향 대칭키

 

암호화 키는 하드코딩 방식으로 구현 내부에 탑재하는 것을 지양하기 때문에 변수 설정 파일(application.properties)에 저장한다.

 

 

JWTUtil

JWT 패키지 내에 JWTUtil 클래스 생성하고 @Component 어노테이션 추가하고 작성하자. 이때 0.12.3 버전과 0.11.5 버전은 내부의 값들이 다르기 때문에 버전에 따라 코드를 작성해야함을 유의할 것

 

private SecretKey secretKey; //SecretKey 객체 변수

public JWTUtil(@Value("${spring.jwt.secret}")String secret) { //SecretKey를 객체 변수로 암호화

    secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}

 

secretKey는 JWT에서 객체 타입으로 만들어서 저장해야 한다. 객체 타입으로 만들어 저장하면서 Key 암호화를 진행해야 한다.

(String Key는 JWT에서 사용하지 않음)

 

.verifyWith(secretKey)

를 이용해서 토큰이 우리 서버에서 생성되었는지, 우리가 가지고 있는 Key랑 맞는지 확인

 

.signWith(secretKey)

secretKey를 통해서 암호화

 

.compact

토큰을 compat

 

로그인 성공 JWT 발급

새로 작성한 JWTUtil을 LoginFIlter에 주입하고 SecurityConfig에서 filter에 주입한다.

 

 

HTTP 인증 방식은 RFC 7235 정의에 따라 아래 인증 헤더 형태를 가져야 한다.

Authorization: 타입 인증토큰
예) Authorization: Bearer 인증토큰string

 

 

LoginFilter 최종

public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JWTUtil jwtUtil;

    public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) {

        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        String username = obtainUsername(request);
        String password = obtainPassword(request);

        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);

        return authenticationManager.authenticate(authToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {

        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();

        String username = customUserDetails.getUsername();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();

        String role = auth.getAuthority();

        String token = jwtUtil.createJwt(username, role, 60*60*10L);

        response.addHeader("Authorization", "Bearer " + token);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {

        response.setStatus(401);
    }
}

 

 

JWT 검증 필터 (JWTFilter 구현)

스프링 시큐리티 filter chain에 요청에 담긴 JWT를 검증하기 위한 커스텀 필터를 등록해야 한다.

 

해당 필터를 통해 요청 헤더 Authorization 키 JWT가 존재하는 경우 JWT를 검증하고 강제로 SecurityContextHolder에 세션을 생성한다. (이 세선은 STATELESS 상태로 관리되기 때문에 해당 요청이 끝나면 소멸 된다.)

 

요청에 대해서 한번만 동작하는 OncePerRequestFIlter를 상속받는 JWTFilter 클래스는 만들고 내부의 특정한 메서드를 구현하자. 그리고 JWTUtill을 생성자 방식으로 주입받자.

 

  1. Request에서 특정한 헤더("Authorization")을 뽑아서 String authorization에 담는다.
  2. authorization 변수에 token이 담겼는지 null인지 인증 방식이 "Bearer " 접두사를 가지는지 확인하기 위해서 if문으로 조건을 단다. token이 없거나 null이거나 접두사가 이상하다면 doFilter를 통해서 request와 response를 다음 필터로 넘겨주고 메서드를 종료한다. (return)
  3. if문으로 검증한 뒤 token을 분리해서(token에서 "Barrer " 접두사를 제거하고) 소멸 시간을 검증한다.
  4. isExpired(token)으로 토큰 소멸 시간 검증
  5. 2개의 토큰 검증을 거친 뒤 
  6. username과 role을 꺼내고 UserEntity에서 초기화 한다. 이때 비밀번호는 token에 담기지 않았지만 같이 초기화 하는데 만약 DB에 비밀번호를 요청하면 매번 DB에서 비밀번호를 요청하기 때문에 임시 비밀번호를 만들어서 넣어준다.
  7. CustomUserDetails에 UserEntity를 넣어서 custonUserDetails를 만든다.
  8. 그리고 customUserDetails, null, customUserDetails.getAuthorities를 담아서 UsernamePasswordAuthenticationToken을 만들어서 Authentication authToken을 생성한다.
  9. authToken을 최종적으로 SecurityContextHolder에 넣으면
  10. 방금 요청에 대해 userSession을 생성할 수 있다. session이 생성되면 특정한 경로에 접근할 수 있다.
  11. 메서드가 종료되었기 때문에 filterChain을 통해 그 다음 필터한테 request, response를 넘겨주면 된다.

 

이제 이 필터를 SecurityConfig에 등록해서 필터가 동작할 수 있도록 하자.

 

http
        .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);

 

JWTFilter를 넣어주는데 그 필터의 위치는 LoginFilter.class 앞에 넣어준다는 뜻

 

 

---------------------까지만 해도 JWT는 동작---------------------

 

 

JWT가 session을 STATELESS한 상태로 관리하긴 하지만 

JWT를 가지고 JWTFilter를 통과한 순간 일시적으로 session을 만들기 때문에 

SecurityContextHolder에서 session에 대한 사용자 이름을 확인할 수 있다.

 

main 페이지나 admin 페이지에 구현해보자.

@Controller
@ResponseBody
public class MainController {

    @GetMapping("/")
    public String mainP() {
			//세션 현재 사용자 아이디
        String username = SecurityContextHolder.getContext().getAuthentication().getName();

			//세션 현재 사용자 Role
			//Collection 내부의 iterator 반복자를 통해서 Role값 추출
     	Authentication authentication = SecurityContextHolder.getContext().getAuthentication();	    
        
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iter = authorities.iterator(); 
        GrantedAuthority auth = iter.next();
        String role = auth.getAuthority();
        
        return "Main Controller : "+ username + role
    }
}

 

JWT는 STATELESS 상태로 관리되긴 하지만 일시적인 요청에 대해선 session을 잠시동안 생성하기 때문에

내부 SecurityContextHolder에서 사용자 정보를 꺼낼 수 있다는 점

 

 

CORS 발생 원리

클라이언트가 웹브라우저를 통해서 사이트에 접속하면 프론트엔드 서버에서 리액트, 뷰와 같은 페이지를 응답해준다. 그러면 프론트 엔드 서버는 보통 3000번대 띄워서 테스트 하고 응답 받은 페이지에서 특정한 내부 데이터를 api 서버한테 호출하게 되면 api 데이터는 8080포트에서 응답하게 된다. 

이렇게 두 개의 포트 번호가 다르기 때문에 웹브라우저 단에서 교차 출처 리소스 공유를 금지시키기 때문에 데이터가 보이지 않게 된다. 

따라서 백엔드 단에서 무조건 CORS 설정을 처리를 시켜주어야 앞 단에서 데이터가 보이기 때문에 무조건 백엔드 단에서 처리를 진행해줘야 한다.

 

 

CORS 설정

SecurityConfig에서 설정하는 방법, Servlet 방식인 MVCConfig에서 설정하는 방법을 모두 처리해줘야 한다. 먼저 기본적으로 Controller단에 들어오는 데이터는 무조건 MVC로 처리해줘야하기 때문에 아래 코드처럼 해주면 되는데

나머지 SecurityFilter를 타는 로그인 방식 부분에는 Security 설정하지 않으면 토큰이 return되지 않는 문제가 발생한다. 따라서 두가지 모두 처리해서 진행해야 한다.

 

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		
		http
            .cors((corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {

                @Override
                public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {

                    CorsConfiguration configuration = new CorsConfiguration();

                    configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
                    configuration.setAllowedMethods(Collections.singletonList("*"));
                    configuration.setAllowCredentials(true);
                    configuration.setAllowedHeaders(Collections.singletonList("*"));
                    configuration.setMaxAge(3600L);

					configuration.setExposedHeaders(Collections.singletonList("Authorization"));

                    return configuration;
                }
            })));

    return http.build();
}

 

 

config>CorsMvcConfig

@Configuration
public class CorsMvcConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry corsRegistry) {
        
        corsRegistry.addMapping("/**")
                .allowedOrigins("http://localhost:3000");
    }
}

 

 

실무에서도 JWT를 Redis나 DB에 refresh Token을 저장하는 작업을 진행한다고 함.. 다음 프로젝트에서 참고하자.