Spring Security와 JWT로 로그인 구현하기 (2)

구환준/모건·2023년 12월 1일

UMC-5th

목록 보기
9/10

JwtSecurityConfig

@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    private final TokenProvider tokenProvider;
    // TokenProvider 를 주입받아서 JwtFilter 를 통해 Security 로직에 필터를 등록
    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
  1. TokenProvider 주입: 클래스 생성자에서 TokenProvider를 주입
  2. configure 메서드 오버라이드: configure 메서드를 오버라이드하여 Spring Security 설정을 구성 - JwtFilter를 생성하고 Spring Security 필터 체인에 등록
  3. JwtFilter 등록:http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class) 코드를 사용하여 JwtFilterUsernamePasswordAuthenticationFilter 앞에 추가로 등록

JwtAuthenticationEntryPoint

@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
        log.error("Unauthorized error: " + authException.getMessage());
    }
}
  • Spring Security에서 사용자의 인증이 실패한 경우 호출되는 핸들러
  1. commence 메서드: AuthenticationEntryPoint 인터페이스를 구현한 클래스에서 제공되는 메서드로, 사용자의 인증이 실패하고 접근이 거부될 때 호출
  2. HTTP 응답 상태 코드 설정: response.sendError(HttpServletResponse.SC_UNAUTHORIZED)를 사용하여 HTTP 응답의 상태 코드를 401 Unauthorized로 설정
  3. 로깅: log.error("Unauthorized error: " + authException.getMessage())를 사용하여 로그에 에러 메시지를 기록

JwtAccessDeniedHandler

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

CorsConfig

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config  = new CorsConfiguration();
        config.setAllowCredentials(true); // 내 서버가 응답을 할 때 json을 자바스크립트에서 처리할 수 있게 할지를 설정하는 것
        config.addAllowedOrigin("*"); // 모든 ip에 응답을 허용하겠다.
        config.addAllowedHeader("*"); // 모든 header에 응답을 허용하겠다.
        config.addAllowedMethod("*"); // 모든 post, get, put, delete, patch 요청을 허용하겠다.

        source.registerCorsConfiguration("**", config); // 들어오는 모든 요청은 이 config를 따르게 한다.
        return new CorsFilter(source);
    }
}
  1. CorsFilter 생성: CorsFilter는 Spring Security와 함께 사용되는 필터로, CORS 구성을 처리하는 데 사용
  2. UrlBasedCorsConfigurationSource 생성: CORS 구성을 등록하기 위한 UrlBasedCorsConfigurationSource 인스턴스를 생성
  3. CorsConfiguration 생성: CorsConfiguration 인스턴스를 생성하여 CORS 구성을 정의
    • setAllowCredentials(true): 자격 증명(인증 정보)을 요청 및 응답에 포함할 것인지
    • addAllowedOrigin("*"): 모든 Origin(도메인)으로부터의 요청을 허용
    • addAllowedHeader("*"): 모든 HTTP 헤더를 허용
    • addAllowedMethod("*"): 모든 HTTP 메서드(GET, POST, PUT, DELETE 등)를 허용
  4. source.registerCorsConfiguration("**", config): 모든 패턴의 URL에 대한 CORS 구성을 CorsConfiguration 객체와 함께 등록. 이렇게 함으로써 모든 경로에 대한 요청은 CORS 정책에 따라 처리

SecurityConfig(제일 중요)

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
@Slf4j
public class SecurityConfig {
    private final TokenProvider tokenProvider;
    private final CorsFilter corsFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().
                requestMatchers(new AntPathRequestMatcher("/h2-console/**"))
                .requestMatchers(new AntPathRequestMatcher( "/favicon.ico"))
                .requestMatchers(new AntPathRequestMatcher( "/css/**"))
                .requestMatchers(new AntPathRequestMatcher( "/js/**"))
                .requestMatchers(new AntPathRequestMatcher( "/img/**"))
                .requestMatchers(new AntPathRequestMatcher( "/lib/**"));
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception {
        // CSRF 설정 Disable
        http.csrf().disable()

                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)

                // exception handling 할 때 우리가 만든 클래스를 추가
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)

                .and()
                .headers()
                .frameOptions()
                .sameOrigin()

                // 시큐리티는 기본적으로 세션을 사용
                // 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless 로 설정
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                // 로그인, 회원가입 API 는 토큰이 없는 상태에서 요청이 들어오기 때문에 permitAll 설정
                .and()
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers(new MvcRequestMatcher(introspector,"/auth/**")).permitAll()
                                .anyRequest().authenticated())
                        .httpBasic(Customizer.withDefaults())  // 나머지 API 는 전부 인증 필요

                // JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
                .apply(new JwtSecurityConfig(tokenProvider));

        return http.build();
    }
}
  1. @EnableWebSecurity: 이 어노테이션은 Spring Security를 활성화하고 웹 보안 설정을 사용하겠다는 것
  2. PasswordEncoder 빈: 비밀번호를 암호화하기 위한 PasswordEncoder 빈을 생성
  3. WebSecurityCustomizer 빈: Spring Security의 웹 보안을 커스터마이징하기 위한 WebSecurityCustomizer 빈을 생성합니다. 여기서는 /h2-console/, /favicon.ico, /css/, /js/, /img/, /lib/ 경로에 대한 요청을 무시하도록 설정
  4. SecurityFilterChain 빈: Spring Security의 보안 필터 체인을 구성하는 SecurityFilterChain 빈을 생성 - Spring Security 필터들의 설정과 연결을 정의
    필터 체인은 다음과 같은 역할을 수행
    - CSRF 설정 비활성화
    - CORS 필터 등록
    - 예외 처리 설정 (인증 및 권한 부여 예외 처리)
    - HTTP 헤더 설정 (X-Frame-Options 등)
    - 세션 관리 설정 (세션을 사용하지 않음)
    - URL 권한 설정 (로그인 및 회원가입 API에 대한 토큰 없이 요청 허용, 나머지 API에 대한 인증 필요)
    - .httpBasic(Customizer.withDefaults()) : 웹 애플리케이션에 HTTP 기본 인증을 추가하고, 기본 설정을 사용하여 이를 구성
    - JWT 필터 등록

SecurityUtil

    public class SecurityUtil {
    
        private SecurityUtil() {
        }
    
        // SecurityContext 에 유저 정보가 저장되는 시점
        // Request 가 들어올 때 JwtFilter 의 doFilter 에서 저장
        public static Long getCurrentMemberId() {
            // SecurityContext 의 Authentication 객체를 이용해 회원 정보를 가져온다.
            final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            // 인증 정보가 없는 경우
            if (authentication == null || authentication.getName() == null) {
                throw  new RuntimeException("Security Context 에 인증 정보가 없습니다.");
            }
    
            return Long.parseLong(authentication.getName());
        }
    }
  1. getCurrentMemberId(): 현재 인증된 사용자의 ID를 반환하는 메서드 - Spring Security의 SecurityContextHolder를 사용하여 현재 사용자의 인증 정보를 가져옴
  2. Authentication 을 이용해 회원의 정보를 불러오고, .getName()을 이용해 회원 정보 속 memberId를 반환

TokenRequestDto

    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    public class TokenRequestDto {
        private String accessToken;
        private String refreshToken;
    }

RefreshTokenRepository

    @Repository
    public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
        Optional<RefreshToken> findByKey(String key);
    }

AuthController

    @RestController
    @RequiredArgsConstructor
    @RequestMapping("/auth")
    public class AuthController {
        private final AuthService authService;
    
        @PostMapping("/signup")
        public ResponseEntity<MemberResponseDto> signup(@RequestBody MemberRequestDto memberRequestDto) {
            return ResponseEntity.ok(authService.signup(memberRequestDto));
        }
    
        @PostMapping("/login")
        public ResponseEntity<TokenDto> login(@RequestBody LoginRequestDto loginRequestDto) {
            return ResponseEntity.ok(authService.login(loginRequestDto));
        }
    
        @PostMapping("/reissue")
        public ResponseEntity<TokenDto> reissue(@RequestBody TokenRequestDto tokenRequestDto) {
            return ResponseEntity.ok(authService.reissue(tokenRequestDto));
        }
    }

AuthService

    @Service
    @RequiredArgsConstructor
    public class AuthService {
    
        private final AuthenticationManagerBuilder authenticationManagerBuilder;
        private final MemberRepository memberRepository;
        private final PasswordEncoder passwordEncoder;
        private final TokenProvider tokenProvider;
        private final RefreshTokenRepository refreshTokenRepository;
    
        @Transactional
        public MemberResponseDto signup(MemberRequestDto memberRequestDto) {
            if (memberRepository.existsByEmail(memberRequestDto.getEmail())) {
                throw new RuntimeException("이미 가입되어 있는 유저입니다.");
            }
            String password = passwordEncoder.encode(memberRequestDto.getPassword());
    
            Member member = Member.builder()
                    .email(memberRequestDto.getEmail())
                    .name(memberRequestDto.getName())
                    .birthday(memberRequestDto.getBirthday())
                    .nickname(memberRequestDto.getNickname())
                    .phoneNumber(memberRequestDto.getPhoneNumber())
                    .password(password)
                    .authority(Authority.valueOf("ROLE_USER"))//User 권한 부여
                    .build();
    
            memberRepository.save(member);
    
            return MemberResponseDto.of(member);
        }
    
        @Transactional
        public TokenDto login(LoginRequestDto loginRequestDto) {
            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(loginRequestDto.getEmail(), loginRequestDto.getPassword());
    
            Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
            TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
    
            RefreshToken refreshToken = RefreshToken.builder()
                    .key(authentication.getName())
                    .value(tokenDto.getRefreshToken())
                    .build();
    
            refreshTokenRepository.save(refreshToken);
            return tokenDto;
        }
    
        @Transactional
        public TokenDto reissue(TokenRequestDto tokenRequestDto) {
            // 1. Refresh Token 검증
            if (!tokenProvider.validateToken(tokenRequestDto.getRefreshToken())) {
                throw new RuntimeException("Refresh Token 이 유효하지 않습니다.");
            }
    
            // 2. Access Token 에서 인증 정보 가져오기
            Authentication authentication = tokenProvider.getAuthentication(tokenRequestDto.getAccessToken());
    
            // 3. 저장소에서 Member ID 를 기반으로 Refresh Token 값 가져옴
            RefreshToken refreshToken = refreshTokenRepository.findByKey(authentication.getName())
                    .orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));
    
            // 4. Refresh Token 일치하는지 검사
            if (!refreshToken.getValue().equals(tokenRequestDto.getRefreshToken())) {
                throw new RuntimeException("토큰의 유저 정보가 일치하지 않습니다.");
            }
    
            // 5. 새로운 토큰 생성
            TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);
    
            // 6. 저장소 정보 업데이트
            RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
            refreshTokenRepository.save(newRefreshToken);
    
            // 토큰 발급
            return tokenDto;
        }
    }
  1. signup 메서드:
    - 먼저 사용자의 이메일이 이미 데이터베이스에 존재하는지 확인하고, 존재한다면 예외
    - 비밀번호는 암호화하여 저장
    - 회원 정보를 생성(User 권한 부여)
  2. login 메서드:
    - 입력받은 이메일과 비밀번호로 UsernamePasswordAuthenticationToken을 생성하여 인증을 시도
    - AuthenticationManagerBuilder를 사용하여 해당 토큰으로 인증을 수행
    - 인증에 성공하면, tokenProvider를 사용하여 액세스 토큰과 리프레시 토큰을 생성
    - 생성된 리프레시 토큰을 데이터베이스에 저장하고, 액세스 토큰과 리프레시 토큰을 포함한 TokenDto를 반환
  3. reissue 메서드:
    - 리프레시 토큰을 사용하여 액세스 토큰을 다시 발급하는 로직
    - 입력받은 리프레시 토큰의 유효성을 검증
    - 유효한 액세스 토큰에서 사용자의 정보를 추출
    - 데이터베이스에서 해당 사용자의 리프레시 토큰을 가져옴
    - 가져온 리프레시 토큰과 입력받은 리프레시 토큰을 비교하여 일치하는지 확인
    - 일치하면 새로운 액세스 토큰을 생성하고, 리프레시 토큰 값을 업데이트한 후 데이터베이스에 저장
    - 새로 발급한 액세스 토큰을 포함한 TokenDto를 반환

    CustomUserDetailsService

        @Service
        @RequiredArgsConstructor
        public class CustomUserDetailsService implements UserDetailsService {
        
            private final MemberRepository memberRepository;
        
            @Override
            @Transactional
            public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
                return memberRepository.findByEmail(email)
                        .map(this::createUserDetails)
                        .orElseThrow(() -> new UsernameNotFoundException(email + "사용자를 찾을 수 없습니다."));
            }
        
            private UserDetails createUserDetails(Member member) {
                GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString());
                return new User(
                        String.valueOf(member.getMemberId()),
                        member.getPassword(),
                        Collections.singleton(grantedAuthority)
                );
            }
        }
    Spring Security의 UserDetailsService 인터페이스의 구현체 UserDetailsService는 Spring Security에서 사용자 정보를 로드하는 인터페이스로, 주로 인증(authentication) 과정에서 사용
    여기서 주요한 역할은 다음과 같다:
    1. loadUserByUsername: 이 메서드는 사용자의 이름(여기서는 이메일)을 받아 해당 사용자의 정보를 로드하고 UserDetails 객체로 반환
      Spring Security는 사용자의 인증을 위해 이 메서드를 호출하여 사용자 정보를 가져옴
    2. createUserDetails: 이 메서드는 Member 엔티티를 기반으로 Spring Security의 UserDetails 객체를 생성
      UserDetails는 사용자의 인증 및 권한 정보를 담는 객체 - 여기서는 Member 엔티티에서 사용자 ID, 패스워드, 권한 정보를 추출하여 User 객체로 감싸서 반환

0개의 댓글