개발놀이터

스프링 시큐리티 + JWT 인증 레이어 추가하기 (4) : 주요 클래스 (SecurityConfig와 OAuth2 Handler) 본문

Spring/Spring Security

스프링 시큐리티 + JWT 인증 레이어 추가하기 (4) : 주요 클래스 (SecurityConfig와 OAuth2 Handler)

마늘냄새폴폴 2023. 5. 20. 05:25

https://coding-review.tistory.com/384

 

스프링 시큐리티 + JWT 인증 레이어 추가하기 (3) : 주요클래스 (JwtService와 Controller)

https://coding-review.tistory.com/383 스프링 시큐리티 + JWT 인증 레이어 추가하기 (2) : 주요 클래스 https://coding-review.tistory.com/382 스프링 시큐리티 + JWT 인증 레이어 추가하기 (1) : 개요 이번 포스팅에선 스

coding-review.tistory.com

이전 포스팅과 이어집니다. 

 

package com.hello.capston.oauth;

import com.hello.capston.jwt.JwtAuthenticationFilter;
import com.hello.capston.jwt.JwtTokenProvider;
import com.hello.capston.jwt.OAuth2LoginSuccessHandler;
import com.hello.capston.jwt.gitbefore.RedisTokenRepository;
import com.hello.capston.jwt.service.JwtService;
import com.hello.capston.jwtDeprecated.JwtUtil;
import com.hello.capston.principal.PrincipalDetailService;
import com.hello.capston.repository.MemberRepository;
import com.hello.capston.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
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.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final PrincipalDetailService principalDetailService;
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate redisTemplate;
    private final MemberRepository memberRepository;
    private final UserRepository userRepository;
    private final RedisTokenRepository redisTokenRepository;

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

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(principalDetailService).passwordEncoder(encodePWD());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().headers().frameOptions().disable()
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .and()
                .authorizeRequests().antMatchers(
                        "/", "/css/**", "/image/**", "/js/**", "/h2-console/**", "/login", "/item_list/**",
                        "/item_detail/**", "/social_login", "/join", "/login_id_duplicate", "/sendEmail", "/checkNumber",
                        "/find_by_detail_category/**", "/item_list_popular/**", "/inquiry/**", "/custom/login", "/custom/logout",
                        "/oauth2/authorization/google", "/oauth2/authorization/naver", "/oauth2/authorization/kakao", "/erase/authToken/authentication"
                ).permitAll()
                .antMatchers("/item_upload", "/detail_upload/**").hasAnyRole("MANAGER", "ADMIN")
                .antMatchers("/admin/**", "/coupon_upload").hasRole("ADMIN")
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, redisTemplate, redisTokenRepository, memberRepository, userRepository), UsernamePasswordAuthenticationFilter.class)
                .oauth2Login().loginPage("/login")
                .and()
                .logout().logoutSuccessUrl("/")
                .and()
                .oauth2Login().userInfoEndpoint().userService(customOAuth2UserService)
                .and()
                .successHandler(new OAuth2LoginSuccessHandler(jwtTokenProvider, redisTemplate, redisTokenRepository, userRepository));

        //중복 로그인
        http.sessionManagement()
                .maximumSessions(1) //세션 최대 허용 수
                .maxSessionsPreventsLogin(false); // false 이면 중복 로그인하면 이전 로그인이 풀린다.
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

좀 복잡하긴 한데... 스프링 시큐리티의 기본적인 메서드는 넘어가도록 하겠습니다. 

 

구조는 이렇습니다. 

 

  1. 인증이 필요없는 URL 설정
  2. ROLE_MANAGER와 ROLE_ADMIN만 입장할 수 있는 URL
  3. ROLE_ADMIN만 입장할 수 있는 URL
  4. UsernamePasswordAuthenticationFilter앞에 우리가 만든 JwtAuthenticationFilter 끼워넣기
  5. OAuth는 자체적으로 커스텀하게 만든 CustomOAuth2UserService 장착
  6. OAuth로그인이 끝나면 SuccessHandler를 실행

4번 같은 경우는 꼭 UsernamePasswordAuthenticationFilter 앞에 우리가 커스텀하게 만든 JwtFilter가 있어야합니다. 

 

왜냐하면 스프링 시큐리티같은 경우는 여러개의 필터 중 하나라도 작동해서 인증에 성공하면 다음 필터는 작동되지 않기 때문입니다. 

 

우리는 로그인과 인증에 JWT를 사용하기 때문에 JWT 필터를 맨 앞에 둬야합니다. 

 

또한 6번같은 경우는 OAuth는 기본적으로 폼 기반의 로그인 (Username과 Password를 넘기는)과는 다른 필터를 사용합니다. 

 

그리고 인증하는 과정도 폼 기반 로그인과 많이 다릅니다. 때문에 일반적인 폼 형태의 로그인은 컨트롤러를 통해 쿠키와 Redis 객체를 생성해주면 됐지만 OAuth는 컨트롤러를 타지 않고 인증을 거칩니다. 

 

때문에 OAuth가 모두 끝났을 때 쿠키와 Redis 객체를 생성해주고 저장해야합니다. 

 

이런 우리에게 아주 꿀맛같은 인터페이스가 있습니다. 이걸 찾아내는데 굉장히 많은 삽질을 했는데...

 

바로 AuthenticationSuccessHandler입니다. (스프링 짱짱... 내가 원하는 인터페이스 웬만한건 다있어 대박)

 

package com.hello.capston.jwt;

import com.hello.capston.entity.User;
import com.hello.capston.jwt.dto.UserResponseDto;
import com.hello.capston.jwt.gitbefore.RedisAndSession;
import com.hello.capston.jwt.gitbefore.RedisTokenRepository;
import com.hello.capston.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseCookie;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.TimeUnit;

@Slf4j
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate redisTemplate;
    private final RedisTokenRepository redisTokenRepository;
    private final UserRepository userRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        UserResponseDto tokenInfo = jwtTokenProvider.generateToken(authentication);

        // NEW!
        String sessionId = request.getSession(true).getId();
        RedisAndSession redisAndSession = RedisAndSession.builder().sessionId(sessionId).refreshToken(tokenInfo.getRefreshToken()).build();
        redisTokenRepository.save(redisAndSession);
        Cookie sessionCookie = new Cookie("SESSION-TOKEN", sessionId);
        sessionCookie.setHttpOnly(true);
        ResponseCookie responseSessionCookie = ResponseCookie
                .from("SESSION-TOKEN", sessionId)
                .maxAge(Duration.ofDays(7))
                .httpOnly(true)
                .path("/")
                .sameSite("Lax")
                .build();
        response.setHeader("Set-Cookie", responseSessionCookie.toString());
        response.addCookie(sessionCookie);
        User principal = (User) authentication.getPrincipal();
        User user = userRepository.findByEmail(principal.getEmail()).map(entity -> entity.updateSessionId(sessionId)).orElse(principal);
        userRepository.save(user);
        // NEW!

        redisTemplate.opsForValue()
                .set("RT:" + authentication.getName(), tokenInfo, tokenInfo.getRefreshTokenExpirationTime(), TimeUnit.MILLISECONDS);

        SecurityContextHolder.getContext().setAuthentication(authentication);

        Cookie cookie = new Cookie("AUTH-TOKEN", tokenInfo.getAccessToken());
        cookie.setHttpOnly(true);

        ResponseCookie responseCookie =
                ResponseCookie.from("AUTH-TOKEN", tokenInfo.getAccessToken())
                        .sameSite("Lax")
                        .httpOnly(true)
                        .maxAge(Duration.ofMinutes(30))
                        .path("/")
                        .build();

        response.addCookie(cookie);
        response.setHeader("Set-Cookie", responseCookie.toString());
        response.sendRedirect("/");
    }
}

뭐가 많이 긴데 그냥 Access Token 쿠키와 Session Id 쿠키, 그리고 Redis 객체를 생성해주는 코드입니다. 그냥 Controller에서 하던 것을 핸들러에서 처리한다 이렇게 생각하시면 됩니다. 

 

 

 

마치며

정말 우여곡절이 많았습니다. 우선 제가 참고한 코드를 제 프로젝트에 걸맞게 커스터마이징 하는 과정에서 많은 삽질이 있었습니다. 

 

아마 이 기능을 만드실 때 기본적인 JWT만들고 쿠키로 간단하게 만드실 분은 상관없겠지만 스프링 시큐리티에 대한 전반적인 이해를 하고 만드시는 것을 추천드립니다. 

 

생각보다 스프링 시큐리티와 JWT를 결합하는 것이 만만치않더군요. 폼 기반 로그인은 어떤 필터를 타고 어떤 과정을 거쳐서 인증이 되는지, OAuth는 어떤 필터를 거치고 어떤 과정으로 인증이 되는지 이에 대한 학습이 조금 부족하다면 만드시는데 큰 어려움이 있을 것이라고 예상합니다. 

 

아래 제가 참고한 깃헙 링크와 블로그 포스팅 링크를 달아두도록 하겠습니다. 

 

 

출처

https://wildeveloperetrain.tistory.com/57

 

spring security + JWT 로그인 기능 파헤치기 - 1

로그인 기능은 거의 대부분의 애플리케이션에서 기본적으로 사용됩니다. 추가로 요즘은 웹이 아닌 모바일에서도 사용 가능하다는 장점과 Stateless 한 서버 구현을 위해 JWT를 사용하는 경우를 많

wildeveloperetrain.tistory.com

=> 이 블로그를 참고했습니다. 

 

https://wildeveloperetrain.tistory.com/50

 

Spring Security 시큐리티 동작 원리 이해하기 - 1

스프링 시큐리티 (Spring Security)는 스프링 기반 어플리케이션의 보안(인증과 권한, 인가)을 담당하는 스프링 하위 프레임워크입니다. 보안과 관련해서 체계적으로 많은 옵션들을 제공해주기 때문

wildeveloperetrain.tistory.com

=> 이 블로그에서 스프링 시큐리티에 대한 전반적인 이해를 권장합니다. 

 

 

깃허브

https://github.com/JianChoi-Kor/Login

 

GitHub - JianChoi-Kor/Login: Login (Spring Security, JWT, Redis, JPA )

Login (Spring Security, JWT, Redis, JPA ). Contribute to JianChoi-Kor/Login development by creating an account on GitHub.

github.com

=> 이분 깃헙을 기반으로 만들었습니다. 제 깃헙 보다는 이분것을 기반으로 만드는 것을 추천드립니다. 제건 너무 클래스도 많고 난잡해서...

 

https://github.com/garlicpollpoll/capston

 

GitHub - garlicpollpoll/capston

Contribute to garlicpollpoll/capston development by creating an account on GitHub.

github.com

=> 혹시 몰라서 제 깃헙 주소도 적어두도록 하겠습니다. 패키지는  capston > jwt 에 모아놨습니다. 포스팅에 있던 컨트롤러는 capston > controller > login > LoginController.java 입니다. 또한 SecurityConfig는 capston > oauth > SecurityConfig 여기에 있습니다.