ABOUT ME

-

  • [Udemy] Spring Security 사용자 정의, JWT
    수업 내용 정리 2024. 11. 23. 17:50

    * 해당 강의에 대한 정리 글입니다.

    https://www.udemy.com/course/spring-security-6-jwt-oauth2-korean/?couponCode=CPSALEBRAND24

    1. Authorization 구현

    권한 기반 인가

    • GrantedAuthority 구현 -> SimpleGrantedAuthority.java: role 필드 보유(String)
    • getAuthority(): 로그인한 사용자에게 할당된 역할, 권한 get
    • 로그인한 사용자의 UserDetails를 Authentication 구현 클래스 객체 형태로 저장: UserDetailsService에서 loadUesrByUsername 시 authorities에 유저의 권한 정보(getRole())를 List로 전달(한 유저가 여러 역할을 가질 수 있으므로)
    @Service
    @RequiredArgsConstructor
    public class EazyBankUserDetailsService implements UserDetailsService {
    
        private final CustomerRepository customerRepository;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            Customer customer = customerRepository.findByEmail(username).orElseThrow(() -> new
                    UsernameNotFoundException("User details not found for the user: " + username));
            List<GrantedAuthority> authorities = customer.getAuthorities().stream().map(authority -> new
                            SimpleGrantedAuthority(authority.getName())).collect(Collectors.toList());
            return new User(customer.getEmail(), customer.getPwd(), authorities);
        }
    }
    • authorities에 @JsonIgnore 어노테이션 추가: 권한 제어를 백엔드에서 수행

    SecurityConfig.java

    @Configuration
    public class ProjectSecurityConfig {
    
        @Bean
        SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
            // CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
            http.securityContext(contextConfig -> contextConfig.requireExplicitSave(false))
                    // .sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
                    // .cors 설정...
                    // .csrf 설정...
                    .authorizeHttpRequests((requests) -> requests
                            .requestMatchers("/myAccount").hasAuthority("VIEWACCOUNT")
                            .requestMatchers("/myBalance").hasAnyAuthority("VIEWBALANCE", "VIEWACCOUNT")
                            .requestMatchers("/myLoans").hasAuthority("VIEWLOANS")
                            .requestMatchers("/myCards").hasAuthority("VIEWCARDS")
                            .requestMatchers("/user").authenticated()
                            .requestMatchers("/notices", "/contact", "/error", "/register", "/invalidSession").permitAll());
            http.formLogin(withDefaults());
            http.httpBasic(hbc -> hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint()));
            http.exceptionHandling(ehc -> ehc.accessDeniedHandler(new CustomAccessDeniedHandler()));
            return http.build();
        }
    • hasAuthority(): requestMatchers()에 해당하는 api에 적용하고자 하는 권한명 전달
      hasAnyAuthority(): 여러 권한을 지정하는 경우
    • access(): 권한 부여에 대한 복잡한 로직 혹은 규칙이 존재하는 경우 사용할 수 있음

     

    역할 기반 인가

    • 역할 관련 테이블 생성: 데이터베이스 내부에는 Role에 해당하는 접두사(default: ROLE_)를 붙이고 생성해야 함

    SecurityConfig.java

    @Configuration
    public class ProjectSecurityConfig {
    
        @Bean
        SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
            // CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
            http.securityContext(contextConfig -> contextConfig.requireExplicitSave(false))
                    // .sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
                    // .cors 설정...
                    // .csrf 설정...
                    .authorizeHttpRequests((requests) -> requests
                            .requestMatchers("/myAccount").hasRole("USER")
                            .requestMatchers("/myBalance").hasAnyRole("USER", "ADMIN")
                            .requestMatchers("/myLoans").hasRole("USER")
                            .requestMatchers("/myCards").hasRole("USER")
                            .requestMatchers("/user").authenticated()
                            .requestMatchers("/notices", "/contact", "/error", "/register", "/invalidSession").permitAll());
            http.formLogin(withDefaults());
            http.httpBasic(hbc -> hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint()));
            http.exceptionHandling(ehc -> ehc.accessDeniedHandler(new CustomAccessDeniedHandler()));
            return http.build();
        }
    • hasRole(): requestMatchers()에 해당하는 api에 적용하고자 하는 역할명 전달
      hasAnyRole(): 여러 역할을 지정하는 경우
    • access(): 권한 부여에 대한 복잡한 로직 혹은 규칙이 존재하는 경우 사용할 수 있음
    • 권한 부여 실패 시 403 에러 반환 -> 리다이렉트하거나 비즈니스 로직에 따라 Authorization 이벤트를 트리거할 수 있음

     

    2. Custom Filter 정의

    • VirtualFilterChain -> 필터체인 내부의 시큐리티 필터를 호출하는 로직
    • (1) Filter Interface 구현: java 내부에 존재(jakarta.servlet)
      doFilter() 오버라이드해서 비즈니스로직 사용자 정의
      init(): 대체로 데이터베이스나 데이터 소스에 연결하는 로직 작성
      destroy(): 대체로 데이터베이스나 데이터 소스와의 연결을 해제하는 로직 작성 
    • (2) GenericFilterBean: spring boot 라이브러리 내에 존재
      서블릿 관련 init 매개변수를 읽어야 하는 요구 사항이 있거나 서블릿 컨텍스트 세부 정보, 환경 속성 세부 정보를 읽을 수 있는 옵션이 필요한 경우 해당 클래스 활용 가능
    • (3) OncePerRequestFilter: GenericFilterBean 확장하여 정의, 각 요청에 대해 필터가 최대 한 번만 실행되도록 보장

    RequestValidationBeforeFilter.java

    public class RequestValidationBeforeFilter implements Filter {
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {
            HttpServletRequest req = (HttpServletRequest) request;
            HttpServletResponse res = (HttpServletResponse) response;
            String header = req.getHeader(HttpHeaders.AUTHORIZATION);
            if(null != header) {
                header = header.trim();
                if(StringUtils.startsWithIgnoreCase(header, "Basic ")) {
                    byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
                    byte[] decoded;
                    try {
                        decoded = Base64.getDecoder().decode(base64Token);
                        String token = new String(decoded, StandardCharsets.UTF_8); // un:pwd
                        int delim = token.indexOf(":");
                        if(delim== -1) {
                            throw new BadCredentialsException("Invalid basic authentication token");
                        }
                        String email = token.substring(0,delim);
                        if(email.toLowerCase().contains("test")) {
                            res.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                            return;
                        }
                    } catch (IllegalArgumentException exception) {
                        throw new BadCredentialsException("Failed to decode basic authentication token");
                    }
                }
            }
            chain.doFilter(request, response);
        }
    }
    • 이메일에 "test"라는 문자가 포함되면 authorize하지 않고 400 오류를 반환하도록 하는 비즈니스 로직
    • chain.doFilter(): 필터 내에서 비즈니스 로직 수행 후 필터체인 내부의 다음 필터 호출

    AuthoritiesLoggingAfterFilter.java

    @Slf4j
    public class AuthoritiesLoggingAfterFilter implements Filter {
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if(null != authentication) {
                log.info("User " + authentication.getName() + " is successfully authenticated and "
                        + "has the authorities " + authentication.getAuthorities().toString());
            }
            chain.doFilter(request,response);
        }
    }

     

    AuthoritiesLoggingAtFilter.java

    @Slf4j
    public class AuthoritiesLoggingAtFilter implements Filter {
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                throws IOException, ServletException {
            log.info("Authentication Validation is in progress");
            chain.doFilter(request,response);
        }
    }

     

     

    CsrfCookieFilter.java

    public class CsrfCookieFilter extends OncePerRequestFilter {
    
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
            CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
            // Render the token value to a cookie by causing the deferred token to be loaded
            csrfToken.getToken();
            filterChain.doFilter(request, response);
        }
    }

     

    SecurityConfig.java

    @Configuration
    public class ProjectSecurityConfig {
    
        @Bean
        SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
            // CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
            http.securityContext(contextConfig -> contextConfig.requireExplicitSave(false))
                    // .sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
                    // .cors 설정...
                    // .csrf 설정...
                    .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
                    .addFilterBefore(new RequestValidationBeforeFilter(), BasicAuthenticationFilter.class)
                    .addFilterAfter(new AuthoritiesLoggingAfterFilter(), BasicAuthenticationFilter.class)
                    .addFilterAt(new AuthoritiesLoggingAtFilter(), BasicAuthenticationFilter.class)
                    // .authorize 설정...
            http.formLogin(withDefaults());
            http.httpBasic(hbc -> hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint()));
            return http.build();
        }
    
    }
    • addFilterBefore(): 두 번째 인자로 작성된 필터 이전에 사용자 정의 필터 실행
    • addFilterAfter(): 두 번째 인자로 작성된 필터 이후에 사용자 정의 필터 실행 -> 같은 순서에 여러 개의 필터가 적용된 경우, 무작위 순서로 실행
    • addFilterAt(): 두 번째 인자로 작성된 필터와 동일한 순서에 사용자 정의 필터 실행 -> 둘 중 어느 필터가 먼저 적용될 지 알 수 없음(무작위 순서로 실행)

     

    3. JWT 기반 인증

    • 클라이언트가 입력과 함께 api 요청 시 백엔드 서버는 자격 증명을 검증하고 토큰을 응답으로 제공
    • 토큰 생성 후 클라이언트 애플리케이션은 보안 api에 접근할 때마다 해당 토큰을 포함하여 요청 전송
    • 토큰이 유효하다면 백엔드 서버는 성공 응답 반환

    JWT - Json Web Token

    • Self-contained: 로그인한 사용자의 역할과 권한에 대한 정보를 저장할 수 있어 백엔드 서버에 매번 의존할 필요가 없음
    • Statelessness: 사용자 정보는 토큰 정보의 일부이기 때문에 세션의 도움으로 애플리케이션이 기억할 필요가 없음 -> 토큰을 읽고 사용자 정보를 가져오는 방식
    • period(.)를 기준으로 Header, Payload, Signature으로 구분하며 Base64로 인코딩
      (1) Header: 토큰에 대한 메타정보 저장 -> 토큰의 종류, 토큰 서명에 따른 알고리즘 저장
      (2) Payload: 사용자와 역할에 대한 정보 저장 -> 비즈니스 로직에 필요한 인가에 사용될 수 있으며 최대한 가벼운 정보로 유지
      (3) Signature: 서명(선택) -> 서명값을 가지고 토큰의 변조 여부를 확인(해싱: header + "." + payload, secret)
    • jwt.io
    • jwt 관련 의존성 추가 -> jsonwebtoken(jjwt-api, impl, jackson)

    SecurityConfig.java

    @Configuration
    public class ProjectSecurityConfig {
    
        @Bean
        SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
            CsrfTokenRequestAttributeHandler csrfTokenRequestAttributeHandler = new CsrfTokenRequestAttributeHandler();
            http.sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                    .cors(corsConfig -> corsConfig.configurationSource(new CorsConfigurationSource() {
                        @Override
                        public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
                            CorsConfiguration config = new CorsConfiguration();
                            config.setAllowedOrigins(Collections.singletonList("http://localhost:4200"));
                            config.setAllowedMethods(Collections.singletonList("*"));
                            config.setAllowCredentials(true);
                            config.setAllowedHeaders(Collections.singletonList("*"));
                            config.setExposedHeaders(Arrays.asList("Authorization"));
                            config.setMaxAge(3600L);
                            return config;
                        }
                    }))
                    .csrf(csrfConfig -> csrfConfig.csrfTokenRequestHandler(csrfTokenRequestAttributeHandler)
                            .ignoringRequestMatchers( "/contact","/register", "/apiLogin")
                            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
                    .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
                    .addFilterBefore(new RequestValidationBeforeFilter(), BasicAuthenticationFilter.class)
                    .addFilterAfter(new AuthoritiesLoggingAfterFilter(), BasicAuthenticationFilter.class)
                    .addFilterAt(new AuthoritiesLoggingAtFilter(), BasicAuthenticationFilter.class)
                    .addFilterAfter(new JWTTokenGeneratorFilter(), BasicAuthenticationFilter.class)
                    .addFilterBefore(new JWTTokenValidatorFilter(), BasicAuthenticationFilter.class)
                    .requiresChannel(rcc -> rcc.anyRequest().requiresInsecure()) // Only HTTP
                    .authorizeHttpRequests((requests) -> requests
                            .requestMatchers("/myAccount").hasRole("USER")
                            .requestMatchers("/myBalance").hasAnyRole("USER", "ADMIN")
                            .requestMatchers("/myLoans").hasRole("USER")
                            .requestMatchers("/myCards").hasRole("USER")
                            .requestMatchers("/user").authenticated()
                            .requestMatchers("/notices", "/contact", "/error", "/register", "/invalidSession", "/apiLogin").permitAll());
            http.formLogin(withDefaults());
            http.httpBasic(hbc -> hbc.authenticationEntryPoint(new CustomBasicAuthenticationEntryPoint()));
            http.exceptionHandling(ehc -> ehc.accessDeniedHandler(new CustomAccessDeniedHandler()));
            return http.build();
        }
    • config.setExposedHeaders(Arrays.asList("Authorization")): 헤더명 Authorization을 백엔드에서 UI로 헤더를 노출하고 동일한 헤더를 사용하여 JWT 값 전송
    • JWT 값을 생성하는 필터와 유효성을 검증하는 커스텀 필터 생성하여 필터 체인에 추가

    JWTTokenGeneratorFilter.java

    public class JWTTokenGeneratorFilter extends OncePerRequestFilter {
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                FilterChain filterChain) throws ServletException, IOException {
            // 현재 인증된 세부 정보를 읽는다.
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            if (null != authentication) { // 인증이 있는 경우 비즈니스 로직 실행
                // 환경 클래스의 객체를 생성해서 getEnvironment() 출력 할당
                Environment env = getEnvironment(); // GenericFilterBean의 구성으로 환경 변수를 읽을 수 있다.
                if (null != env) {
                    // 환경 변수로 지정된 시크릿 값을 가져온다. -> 없는 경우 고려할 기본값을 함께 전달한다.
                    String secret = env.getProperty(ApplicationConstants.JWT_SECRET_KEY,
                            ApplicationConstants.JWT_SECRET_DEFAULT_VALUE);
                    // 시크릿 값을 바이트 형태로 전달해서 시크릿 키 셋
                    SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
                    String jwt = Jwts.builder().issuer("Eazy Bank").subject("JWT Token") // issuer(): 발행 조직 명확성
                            .claim("username", authentication.getName()) // 제공할 정보 추가
                            .claim("authorities", authentication.getAuthorities().stream().map(
                                    GrantedAuthority::getAuthority).collect(Collectors.joining(",")))
                            .issuedAt(new Date())
                            .expiration(new Date((new Date()).getTime() + 30000000)) // 토큰 만료 시간 설정
                            .signWith(secretKey).compact(); // 디지털 서명 추가하고 .compact(): 문자열 형식으로 변환
                    response.setHeader(ApplicationConstants.JWT_HEADER, jwt); // 헤더에 "Authorization" 추가하고 jwt 변수에 jwt 할당
                }
            }
            // 요청과 응답을 전달하여 다음 필터를 호출한다.
            filterChain.doFilter(request, response);
        }
    
        @Override
        protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
            return !request.getServletPath().equals("/user");
        }
    
    }
    • 요청에 따라 해당 로직은 인증이 성공한 후에 한 번만 실행되어야 함
    • /user 경로가 아닌 요청의 경우 해당 필터는 실행되지 않아야 함
    • ApplicationConstants 파일을 final class로 생성하여 환경 변수 값을 저장한다.(public static final JWT_SECRET ...)
    • claim(): jwt 객체 내부에 전달할 값 추가
    public final class ApplicationConstants {
    
        public static final String JWT_SECRET_KEY = "JWT_SECRET";
        public static final String JWT_SECRET_DEFAULT_VALUE = "jxgEQeXHuPq8VdbyYFNkANdudQ53YUn4";
        public static final String JWT_HEADER = "Authorization";
    }

     

    JWTTokenValidatorFilter.java

    public class JWTTokenValidatorFilter extends OncePerRequestFilter {
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
           String jwt = request.getHeader(ApplicationConstants.JWT_HEADER);
           if(null != jwt) {
               try {
                   Environment env = getEnvironment();
                   if (null != env) {
                       String secret = env.getProperty(ApplicationConstants.JWT_SECRET_KEY,
                               ApplicationConstants.JWT_SECRET_DEFAULT_VALUE);
                       SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
                       if(null != secretKey) {
                           // claims에서 username, authority 정보 가져옴
                           Claims claims = Jwts.parser().verifyWith(secretKey)
                                    .build().parseSignedClaims(jwt).getPayload(); // parseSignedClaims에서 헤더 정보 가져옴
                           String username = String.valueOf(claims.get("username"));
                           String authorities = String.valueOf(claims.get("authorities"));
                           // 위 작업에서 얻은 username과 authorities를 제공하고 password는 null로 전달한다.
                           // authentication 객체를 생성할 때 마다 Authenticated는 true로 생성된다. (이미 인증이 완료됨)
                           Authentication authentication = new UsernamePasswordAuthenticationToken(username, null,
                                   AuthorityUtils.commaSeparatedStringToAuthorityList(authorities));
                           // setAuthentication(): 인증 객체를 SecurityContextHolder에 저장한다.
                           SecurityContextHolder.getContext().setAuthentication(authentication);
                       }
                   }
    
               } catch (Exception exception) {
                   // JWT 값 검증에 실패하는 경우 발생하는 예외 처리
                   throw new BadCredentialsException("Invalid Token received!");
               }
           }
            filterChain.doFilter(request,response);
        }
    
        @Override
        protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
            return request.getServletPath().equals("/user");
        }
    
    }
    • /user 경로의 요청의 경우 해당 필터는 실행되지 않아야 하며 이후의 모든 보안 api에 대한 요청은 해당 필터가 실행되어야 함

    Client

    • Authorization 값을 sessionStorage에 설정(로컬 스토리지에 저장하지 않음)하고 requestHeader에 해당 값을 전달
    • logout 시 Authorization 값을 null로 변경해서 권한 삭제

    사용자 인증이 필요한 REST API 구축(인증 수동 호출)

    SecurityConfig.java

        @Bean
        public AuthenticationManager authenticationManager(UserDetailsService userDetailsService,
                PasswordEncoder passwordEncoder) {
            EazyBankUsernamePwdAuthenticationProvider authenticationProvider =
                    new EazyBankUsernamePwdAuthenticationProvider(userDetailsService, passwordEncoder);
            ProviderManager providerManager = new ProviderManager(authenticationProvider);
            // Authentication 객체 내부의 비밀번호를 삭제하지 않음(다른 유효성 검사를 위해 비밀번호를 사용하는 경우에 설정)
            providerManager.setEraseCredentialsAfterAuthentication(false);
            return providerManager;
        }
    • 메소드 내에서 인증 제공자 객체 생성

    UserController.java

    @PostMapping("/apiLogin")
        public ResponseEntity<LoginResponseDTO> apiLogin (@RequestBody LoginRequestDTO loginRequest) {
            String jwt = "";
            // 로그인 요청을 Authentication 객체로 변환(생성)
            Authentication authentication = UsernamePasswordAuthenticationToken.unauthenticated(loginRequest.username(),
                    loginRequest.password());
            // authenticationManager을 통해 인증에 대한 결과를 Authentication 객체로 반환
            Authentication authenticationResponse = authenticationManager.authenticate(authentication);
            if(null != authenticationResponse && authenticationResponse.isAuthenticated()) { // 인증이 성공한 경우
                if (null != env) {
                // 인증에 성공한 경우 jwt를 수동으로 생성하는 로직 실행
                    String secret = env.getProperty(ApplicationConstants.JWT_SECRET_KEY,
                            ApplicationConstants.JWT_SECRET_DEFAULT_VALUE);
                    SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
                     jwt = Jwts.builder().issuer("Eazy Bank").subject("JWT Token")
                            .claim("username", authenticationResponse.getName())
                            .claim("authorities", authenticationResponse.getAuthorities().stream().map(
                                    GrantedAuthority::getAuthority).collect(Collectors.joining(",")))
                            .issuedAt(new java.util.Date())
                            .expiration(new java.util.Date((new java.util.Date()).getTime() + 30000000))
                            .signWith(secretKey).compact();
                }
            }
            // HttpStatus.OK를 반환하면서 헤더명을 Authentication으로 jwt값을 함께 전송하고 body에 responseDTO와 jwt 값을 전송
            // 보통은 한 곳에만 전송한다...
            return ResponseEntity.status(HttpStatus.OK).header(ApplicationConstants.JWT_HEADER,jwt)
                    .body(new LoginResponseDTO(HttpStatus.OK.getReasonPhrase(), jwt));
        }
    • jwt 값을 responseBody에 보내는 rest api 구현
    • LoginRequestDto, LoginResponseDto 생성

    '수업 내용 정리' 카테고리의 다른 글

    [Udemy] Spring Security 예외 처리, CORs, CSRF  (0) 2024.11.22
    [Udemy] Spring Security Basic  (1) 2024.11.21
    Argo cd  (3) 2024.10.17
    [Udemy] Twitter4j & Kafka  (0) 2024.09.23
    docker 설정  (0) 2024.08.26