Spring Security

이동훈·2023년 1월 12일

Spring Security

spring security를 사용한 로그인 시스템

  • 인증과 권한에 관한 부분을 필터의 흐름에 따라 처리
  • Client의 Request EndPoint를 확인하여 지정된 URL로 오는 경우 각각의 필터로 처리

필터의 흐름

로그인 흐름

  1. Server로 로그인 요청
  2. CORS 필터
  3. UsernamePasswordAuthentication을 상속 받은 UserAuthenticationFilter를 구현하여 유효한 사용자인지 확인
  4. JWT 토큰 발행
  5. HTTP 헤더에 토큰을 포함하여 Client에 응답

로그인 이후 흐름

  1. Client에서 Controller에 맵핑된 주소로 요청
  2. CORS 필터
  3. BasicAuthenticationFilter를 상속 받은 JwtTokenAuthorizationFilter를 구현하여 HTTP 헤더에 담긴 토큰 검증
  4. DB 조회를 통하여 사용자 검증
  5. Security Session에 authentication 객체 저장
  6. 다음 필터로 이동

사용된 필터

  • LogoutFilter
    /**
     * csrf, cors 등 web security 에서 설정하는 전역 필터 설정 클래스
     * 커스텀 필터 환경 변수 설정 클래스
     * corsFilter() : cross-origin resource sharing 허용 설정
     * authenticationFilter() : 유저 인증 메소드 빈 주입 및 환경 변수 설정
     * authorizationFilter() : 유저 인가 메소드 빈 주입 및 환경 변수 설정
     */
    
    @Configuration
    @RequiredArgsConstructor
    public class GlobalFilter {
    
     // 필드 변수 생략
    
    	@Bean
    	public CorsFilter corsFilter() {
    		UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    		CorsConfiguration config = new CorsConfiguration();
    		config.setAllowCredentials(true);
    		config.addAllowedOriginPattern(corsPattern);
    //		config.addAllowedOrigin(clientURL);
    		config.addAllowedHeader(corsHeader);
    		config.addAllowedMethod(corsMethod);
    		config.addExposedHeader(headerAccess);
    		config.addExposedHeader(headerRefresh);
    		source.registerCorsConfiguration(corsSource, config);
    		return new CorsFilter(source);
    	}
    
    	@Bean
    	public UserAuthenticationFilter authenticationFilter() {
    		UserAuthenticationFilter userAuthenticationFilter = new UserAuthenticationFilter(userAuthenticationManager, jwtTokenProvider, tokenRepository);
    		userAuthenticationFilter.setHeaderKeyAccess(headerAccess);
    		userAuthenticationFilter.setHeaderKeyRefresh(headerRefresh);
    		userAuthenticationFilter.setTypeAccess(typeAccess);
    		userAuthenticationFilter.setTypeRefresh(typeRefresh);
    
    		return userAuthenticationFilter;
    	}
    
    	@Bean
    	public JwtTokenAuthorizationFilter authorizationFilter() {
    		JwtTokenAuthorizationFilter jwtTokenAuthorizationFilter = new JwtTokenAuthorizationFilter(userAuthenticationManager, jwtTokenProvider, principalDetailService);
    		jwtTokenAuthorizationFilter.setHeaderKeyAccess(headerAccess);
    		jwtTokenAuthorizationFilter.setTypeAccess(typeAccess);
    
    		return jwtTokenAuthorizationFilter;
    	}
    
    // ... 이후 메소드 생략
    }
  • UsernamePasswordAuthenticationFilter
    /**
     * URL 이 /login 으로 넘어올 경우 spring security 에서 자동으로 attemptAuthentication() 으로 보내줌
     * attemptAuthentication() 에서 유저 정보를 확인 후 성공하면 successfulAuthentication()
     * 실패할 경우 unsuccessfulAuthentication() 으로 자동 이동 시켜줌
     */
    
    @Slf4j
    @Setter
    @Component
    public class UserAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    	private static final String METHOD_NAME = "UserAuthenticationFilter";
    	private UserAuthenticationManager userAuthenticationManager;
    	private JwtTokenProvider jwtTokenProvider;
    	private TokenRepository tokenRepository;
    	private String headerKeyAccess;
    	private String headerKeyRefresh;
    	private String typeAccess;
    	private String typeRefresh;
    
    	@Autowired
    	public UserAuthenticationFilter(UserAuthenticationManager userAuthenticationManager, JwtTokenProvider jwtTokenProvider, TokenRepository tokenRepository) {
    		super(userAuthenticationManager);
    		this.userAuthenticationManager = userAuthenticationManager;
    		this.jwtTokenProvider = jwtTokenProvider;
    		this.tokenRepository = tokenRepository;
    	}
    
    	@Override
    	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    		log.info(METHOD_NAME + "- attemptAuthentication() ...");
    		try {
    			Employee employee = new ObjectMapper().readValue(request.getInputStream(), Employee.class);
    
    			UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(employee.getEmpNo(), employee.getPassword());
    
    			return userAuthenticationManager.authenticate(authenticationToken);
    		} catch (IOException ie) {
    			log.error("유저 정보를 읽지 못했습니다. " + METHOD_NAME, ie);
    			unsuccessfulAuthentication(request, response, ie);
    		} catch (NullPointerException ne) {
    			log.error("받은 유저 정보가 비어 있습니다. " + METHOD_NAME, ne);
    			unsuccessfulAuthentication(request, response, ne);
    		} catch (Exception e) {
    			log.error("SERVER ERROR " + METHOD_NAME, e);
    			unsuccessfulAuthentication(request, response, e);
    		}
    		log.error("자격 증명에 실패하였습니다. " + METHOD_NAME);
    		return null;
    	}
    
    	@Override
    	protected void successfulAuthentication(HttpServletRequest request,
    											HttpServletResponse response,
    											FilterChain chain, Authentication authResult) throws ServletException {
    		log.info(METHOD_NAME + "- successfulAuthentication() ...");
    
    		try {
    			String principal = String.valueOf(authResult.getPrincipal());
    
    			Token token = tokenRepository.findByEmpNo(principal);
    			if (token != null) {
    				log.info("Token Set Existed - Token issuance");
    				CommonTokenDTO commonTokenDTO = jwtTokenProvider.generateToken(principal);
    				if (!jwtTokenProvider.updateRefresh(commonTokenDTO.getReIssuanceTokenDTO()))
    					log.warn("Token Set Update to Token Repository - Fail");
    
    				response.addHeader(headerKeyAccess, typeAccess + commonTokenDTO.getAccessToken());
    				response.addHeader(headerKeyRefresh, typeRefresh + commonTokenDTO.getReIssuanceTokenDTO().getRefreshToken());
    			} else {
    				log.info("First Login User - Token issuance");
    				CommonTokenDTO commonTokenDTO = jwtTokenProvider.generateToken(principal);
    				if (!jwtTokenProvider.saveRefresh(commonTokenDTO.getReIssuanceTokenDTO()))
    					log.warn("Token Set Save to Token Repository - Fail");
    
    				response.addHeader(headerKeyAccess, typeAccess + commonTokenDTO.getAccessToken());
    				response.addHeader(headerKeyRefresh, typeRefresh + commonTokenDTO.getReIssuanceTokenDTO().getRefreshToken());
    			}
    			response.setContentType("text/html; charset=UTF-8");
    			response.getWriter().write(new ResponseHandler().convertResult(HttpStatus.OK, Payload.SIGN_IN_OK));
    		} catch (IOException ie) {
    			log.error("유저 정보를 읽지 못했습니다. " + METHOD_NAME, ie);
    		} catch (NullPointerException ne) {
    			log.error("받은 유저 정보가 비어 있습니다. " + METHOD_NAME, ne);
    		} catch (Exception e) {
    			log.error("SERVER ERROR " + METHOD_NAME, e);
    		}
    	}
    
    	@Override
    	protected void unsuccessfulAuthentication(HttpServletRequest request,
    											  HttpServletResponse response,
    											  AuthenticationException failed) throws ServletException {
    		log.info(METHOD_NAME + "- unsuccessfulAuthentication() ...");
    
    		try {
    			String message = new UserLoginFailureHandler().onAuthenticationFailure(failed);
    
    			response.setContentType("text/html; charset=UTF-8");
    			response.getWriter().write(new ResponseHandler().convertResult(HttpStatus.BAD_REQUEST, Payload.SIGN_IN_FAIL + message));
    		} catch (IOException ie) {
    			log.error("전달받은 정보를 읽지 못했습니다. " + METHOD_NAME, ie);
    		} catch (Exception e) {
    			log.error("SERVER ERROR " + METHOD_NAME, e);
    		}
    	}
    
    	public void unsuccessfulAuthentication(HttpServletRequest request,
    										   HttpServletResponse response,
    										   Exception exception) {
    		log.info(METHOD_NAME + "- unsuccessfulAuthentication() ...");
    
    		try {
    			SecurityContextHolder.clearContext();
    			String message = new UserLoginFailureHandler().onAuthenticationFailure(exception);
    
    			response.setContentType("text/html; charset=UTF-8");
    			response.getWriter().write(new ResponseHandler().convertResult(HttpStatus.BAD_REQUEST, Payload.SIGN_IN_FAIL + message));
    		} catch (IOException ie) {
    			log.error("전달받은 정보를 읽지 못했습니다. " + METHOD_NAME, ie);
    		} catch (Exception e) {
    			log.error("SERVER ERROR " + METHOD_NAME, e);
    		}
    	}
    }
  • BasicAuthenticationFilter
    /**
     * 제외 지정한 URL 이 아닌 모든 URL 이 인가된 Token 을 보유하고 있는지 검증하는 클래스
     * 액세스 토큰 보유를 확인해서 올바른 토큰을 보유하고 있는 경우 doFilter 를 사용하여 다음 필터로 패스
     * 토큰이 올바르지 않을 경우 Fail Response 를 보냄
     * 리프레쉬 토큰을 보유한 경우 리프레쉬 토큰이 올바르면 액세스 토큰을 다시 리턴해 줌
     */
    
    @Slf4j
    @Setter
    @Component
    public class JwtTokenAuthorizationFilter extends BasicAuthenticationFilter {
    	private static final String METHOD_NAME = "JwtTokenAuthorizationFilter";
    	private final JwtTokenProvider jwtTokenProvider;
    	private final PrincipalDetailService principalDetailService;
    	private String headerKeyAccess;
    	private String typeAccess;
    
    	@Autowired
    	public JwtTokenAuthorizationFilter(UserAuthenticationManager userAuthenticationManager, JwtTokenProvider jwtTokenProvider, PrincipalDetailService principalDetailService) {
    		super(userAuthenticationManager);
    		this.jwtTokenProvider = jwtTokenProvider;
    		this.principalDetailService = principalDetailService;
    	}
    
    	@Override
    	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    		log.info(METHOD_NAME + "- doFilterInternal() ...");
    		try {
    			TokenResDTO tokenResDTO = jwtTokenProvider.requestCheckToken(request);
    			String token = tokenResDTO.getToken();
    			switch (tokenResDTO.getCode()) {
    				case 0:
    					if (jwtTokenProvider.validateToken(token)) {
    						log.info("Access Token Validation - Success");
    
    						String userPk = jwtTokenProvider.getUserPk(token);
    
    						UserDetails userDetails = principalDetailService.loadUserByUsername(userPk);
    						UsernamePasswordAuthenticationToken authenticationToken =
    								new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    
    						authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
    						SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    
    						filterChain.doFilter(request, response);
    					} else {
    						log.info("Access Token Validation - Fail");
    
    						response.setContentType("text/html; charset=UTF-8");
    						response.getWriter().write(new ResponseHandler().convertResult(HttpStatus.BAD_REQUEST, Payload.ACCESS_FAIL + Payload.TOKEN_FAIL));
    					}
    					return;
    				case 1:
    					if (jwtTokenProvider.validateRefreshToken(token)) {
    						log.info("Refresh Token Validation - Success");
    						String accessToken = jwtTokenProvider.generateAccessToken(jwtTokenProvider.getUserPk(token));
    
    						response.addHeader(headerKeyAccess, typeAccess + accessToken);
    
    						response.setContentType("text/html; charset=UTF-8");
    						response.getWriter().write(new ResponseHandler().convertResult(HttpStatus.OK, Payload.TOKEN_OK));
    					} else {
    						log.info("Refresh Token Validation - Fail");
    
    						response.setContentType("text/html; charset=UTF-8");
    						response.getWriter().write(new ResponseHandler().convertResult(HttpStatus.BAD_REQUEST, Payload.ACCESS_FAIL + Payload.TOKEN_FAIL));
    					}
    					return;
    				case 2:
    				default:
    					log.warn("Access/Refresh Token Validation - Fail");
    			}
    		} catch (NullPointerException ne) {
    			log.error("토큰 값이 비어있습니다. " + METHOD_NAME);
    		} catch (Exception e) {
    			log.error("사용자 인증을 확인하지 못해 인가할 수 없습니다. " + METHOD_NAME, e);
    		}
    
    		filterChain.doFilter(request, response);
    	}
    }
  • 이후 유저의 역할 관리는 Interceptor에서 처리

Interceptor

  1. filter 와의 차이점
    • 필터는 Dispatcher Servlet에 요청이 전달 되기 전에 작업 처리
    • 스프링 범위 밖에서 Web Context가 작업
    • Interceptor는 Dispatcher Servlet과 Controller 사이에서 작업 처리
    • Spring Context안에서 작동
    • Controller를 호출하기 전에 요청과 응답을 참조하거나 가공
  2. 용도
    • 필터는 공통된 보안 및 인증/인가 관련 작업
    • 인터셉터는 세부적인 보안 작업 및 Controller로 넘겨주는 정보 가공
    • 필터는 요청과 응답을 가공할 수 있다
    • 인터셉터는 요청과 응답을 가공할 수 없음 통과 실패 처리만 존재
  3. 사용한 인터셉터
    • 역할 나누기 용 인터셉터
      /**
       *지정되지 않은 모든URL을 가져와 검사
      * URL이/admin, /emp인지 그 외인지 검사하여boolean리턴
      */
      @Slf4j
      @Component
      public class RoleInterceptor implements HandlerInterceptor {
         private static final StringMETHOD_NAME= "RoleInterceptor";
         private final DecodeEncodeHandler decodeEncodeHandler;
         private final JwtTokenProvider jwtTokenProvider;
         private final String adminRole;
         private final String employeeRole;
         private final String adminURL;
         private final String employeeURL;
      
         @Autowired
         public RoleInterceptor(DecodeEncodeHandler decodeEncodeHandler, JwtTokenProvider jwtTokenProvider,
                           @Value(value = "${user.role.admin}") String adminRole,
                           @Value(value = "${user.role.employee}") String employeeRole,
                           @Value(value = "${user.url.admin}") String adminURL,
                           @Value(value = "${user.url.employee}") String employeeURL) {
            this.decodeEncodeHandler = decodeEncodeHandler;
            this.jwtTokenProvider = jwtTokenProvider;
            this.adminRole = adminRole;
            this.employeeRole = employeeRole;
            this.adminURL = adminURL;
            this.employeeURL = employeeURL;
         }
      
         @Override
         public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            log.info(METHOD_NAME + "- preHandle() ...");
            boolean result = false;
            try {
               TokenResDTO tokenResDTO = jwtTokenProvider.requestCheckToken(request);
               String token = tokenResDTO.getToken();
               Outer:
               {
                  if (jwtTokenProvider.validateToken(token)) {
                     log.info("Token validate - success");
                     String email = jwtTokenProvider.getUserPk(token);
      
                     if (decodeEncodeHandler.empNoValid(email)) {
                        log.info("User validate - Success");
                        String role = decodeEncodeHandler.roleValid(email);
                        if (request.getRequestURI().startsWith(adminURL)) {
                           log.info("ADMIN role validate ...");
                           if (role != null && role.equals(adminRole)) {
                              log.info("ADMIN role validate - Success");
                              result = true;
                           } else {
                              log.warn("ADMIN role validate - Fail");
                              response.setContentType("text/html; charset=UTF-8");
                              response.getWriter().write(new ResponseHandler().convertResult(HttpStatus.BAD_REQUEST, Payload.USER_ROLE_CHECK_FAIL));
                           }
                           break Outer;
                        }
                        if (request.getRequestURI().startsWith(employeeURL)) {
                           log.info("USER role validate ...");
                           if (role != null && role.equals(employeeRole)) {
                              log.info("USER role validate - Success");
                              result = true;
                           } else {
                              log.warn("USER role validate - Fail");
                              response.setContentType("text/html; charset=UTF-8");
                              response.getWriter().write(new ResponseHandler().convertResult(HttpStatus.BAD_REQUEST, Payload.USER_ROLE_CHECK_FAIL));
                           }
                           break Outer;
                        }
                        log.warn("Unverified role ACCESS ... ");
      
                     } else {
                        log.warn("Request User is not exist " + METHOD_NAME);
                        response.setContentType("text/html; charset=UTF-8");
                        response.getWriter().write(new ResponseHandler().convertResult(HttpStatus.BAD_REQUEST, Payload.USER_ROLE_CHECK_FAIL));
                     }
                  } else {
                     log.warn("Token validate - Fail");
                     response.setContentType("text/html; charset=UTF-8");
                     response.getWriter().write(new ResponseHandler().convertResult(HttpStatus.BAD_REQUEST, Payload.TOKEN_FAIL));
                  }
               }
               return result;
            } catch (IOException ie) {
               log.error("역할이 입력되지 않았습니다. " + METHOD_NAME, ie);
            } catch (NullPointerException ne) {
               log.error("역할이 존재하지 않습니다. " + METHOD_NAME, ne);
            } catch (Exception e) {
               log.error("SERVER ERROR " + METHOD_NAME, e);
            }
            return false;
         }
      }
      
    • 인터셉터 설정
      /**
       * Dispatcher Servlet 을 통과 후 Controller 에 도달하기 전에 지정된 URL 을 통과할 경우 작동
       * 여러 Interceptor 를 설정하여 작업 처리 가능
       * order() 메소드를 통해 Interceptor 의 처리 순서 결정
       */
      
      @Configuration
      @RequiredArgsConstructor
      public class InterceptorConfig implements WebMvcConfigurer {
      	private final RoleInterceptor roleInterceptor;
      
      	@Override
      	public void addInterceptors(InterceptorRegistry registry) {
      		registry.addInterceptor(roleInterceptor)
      				.order(1)
      				.addPathPatterns("/**")
      				.excludePathPatterns("/sign");
      	}
      }

로그아웃 흐름

  1. Server의 지정된 로그아웃 URL로 로그아웃 요청
  2. LogoutHandler로 지정된 작업 처리
  3. 서버 세션에 남아있는 authentication 객체와 쿠키 삭제 및 SecurityContextHolder 클리어
  4. LogoutSuccessHandler를 사용하여 로그아웃 성공했을 시 서버 응답
profile
Fool Snack Developer

0개의 댓글