Spring Security는 Spring 기반의 애플리케이션의 보안 (인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크이다.

인증과 인가

  • 인증(Authentication) : 해당 사용자가 본인이 맞는지 신원을 확인하는 절차
  • 인가(Authorization) : 인증된 사용자가 요청한 자원에 접근 가능한지 권한을 확인하는 절차

필터와 인터셉터

  • 필터(Filter) : Dispatcher Servlet에 요청이 전달되기 전/후에 url 패턴에 맞는 모든 요청에 대한 작업을 처리할 수 있는 기능 제공 (웹 컨테이너(톰캣)에 의해 관리됨)
  • 인터셉터(Interceptor) : Spring이 제공하는 기술로 Dispatch Servlet이 컨트롤러를 호출하기 전과 후에 끼어들어 스프링 컨텍스트 내부에서 Controller(Handler)에 관한 요청과 응답에 대해 처리

그러면, 스프링 시큐리티는 어떻게 톰캣의 필터를 사용할까?

DelegatingFilterProxy

Spring Security는 사용하고자 하는 FilterChain들을 Servlet Container 기반의 필터 위에서 동작시키기 위해 DelegatingFIlterProxy라는 클래스를 이용한다. IOC 컨테이너에서 관리하는 빈이 아닌 표준 서블릿 필터를 구현하고 있고 내부에 위임대상(FilterChainProxy)를 갖고 있다. 즉, DelegatingFilterProxy는 서블릿 필터이며, Spring IOC 컨테이너가 관리하는 Filter Bean을 갖고 있고 이 Filter Bean은 위임대상이며 이 객체안에서 security와 관련된 일들이 벌어진다.

동작 원리

  1. 사용자가 아이디와 비밀번호를 입력하여 로그인 요청
  2. Authentication Filter에서 Http Request을 받고 토큰 생성 (UsernamePasswordAuthenticationToken)
  3. Authentication Filter는 토큰을 AuthenticationManager에게 보내면서 인증을 위임
  4. AuthenticationManager는 AuthenticationProvider에게 인증용 객체를 다시 위임
  5. AuthenticationProvidere 인터페이스는 DB에 있는 사용자 정보와 인증용 객체에 담긴 정보를 비교
    5-1. UserDetailsService 인터페이스의 loadUserByUsername()메서드를 호출하여 사용자 정보를 UserDetails타입으로 가져옴
  6. AuthenticationProvider의 authenticate()를 이용하여 사용자 인증을 수행
    6-1. DB에서 가져온 사용자의 비밀번호는 해시값으로 저장되어 있고, 사용자가 입력한 비밀번호를 PasswordEncoder를 이용하여 해시한 후에 비교한다. (matches() 메서드로 비교)
  7. 인증에 성공하면, AuthenticationProvider에서 인증된 객체를 Authentication 객체에 담아 AuthenticationManager -> AuthenticationFilter에게 전달
  8. AuthenticationFilter는 SecurityContextHolder라는 곳에 담은 훟, AuthenticationSuccessHandler를 실행
    8-1. 실패시에 AuthenticationFailureHandler를 실행
  9. 이후에 인증된 사용자가 다시 요청을 보내게 되면, SecurityContext 객체 안에 Authentication이 있는지 확인 후, 있다면 바로 처리해주는 형식

프로젝트 적용

  • security-context 파일을 상위컨테이너 위치에서 생성하기
  • web.xml에 DelegatingFilterProxy 필터 등록하기
<!-- needed for ContextLoaderListener -->
<context-param>
		<param-name>contextConfigLocation</param-name>
		<param-value>classpath:kr/or/ddit/spring/conf/*-context.xml</param-value>
</context-param>

<!-- Bootstraps the root web application context before servlet initialization -->
<listener>
		<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

<filter>
	<filter-name>springSecurityFilterChain</filter-name>
	<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
	<async-supported>true</async-supported>
</filter>
<filter-mapping>
	<filter-name>springSecurityFilterChain</filter-name>
	<url-pattern>/*</url-pattern>
</filter-mapping>
  • security-context.xml에서 AuthenticationProvider에 PasswordEncoder, userDetailsService 등록하기
  • AuthenticationManager에 AuthenticationProvider 등록하기
<bean id="passwordEncoder" class="org.springframework.security.crypto.factory.PasswordEncoderFactories" 
	factory-method="createDelegatingPasswordEncoder"
/>
	
<bean id="daoAuthenticationProvider" class="org.springframework.security.authentication.dao.DaoAuthenticationProvider" 
	p:userDetailsService-ref="accountServiceImpl"
	p:passwordEncoder-ref="passwordEncoder"
	p:hideUserNotFoundExceptions="false"
/>

<security:authentication-manager>
	<security:authentication-provider ref="daoAuthenticationProvider" />
</security:authentication-manager>
  • UserDetails를 구현한 User 상속받고 부모 생성자 이용하여 생성자 정의
	// User 생성자
	public User(String username, String password, boolean enabled, boolean accountNonExpired,
			boolean credentialsNonExpired, boolean accountNonLocked,
			Collection<? extends GrantedAuthority> authorities) {
		Assert.isTrue(username != null && !"".equals(username) && password != null,
				"Cannot pass null or empty values to constructor");
		this.username = username;
		this.password = password;
		this.enabled = enabled;
		this.accountNonExpired = accountNonExpired;
		this.credentialsNonExpired = credentialsNonExpired;
		this.accountNonLocked = accountNonLocked;
		this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));
	}

  @SuppressWarnings("serial")
  public class AccountWrapper<T> extends User {
      private T realUser;
      private String accInfo;

      public AccountWrapper(AccountVO account, T realUser) {
          super(
              account.getAccId(),	// username
              account.getAccPw(),	// password
              !account.isAccDel(),	// enabled
              true,	// accountNonExpired
              true,	// credentialNonExpired
              true,	// accountNonLocked
              AuthorityUtils.createAuthorityList(account.getAccAuth())	// authorities
          );	
          this.realUser = realUser;
          this.accInfo = account.getAccInfo();
      }

      public T getRealUser() {
          return realUser;
      }

      public String getAccInfo() {
          return accInfo;
      }
  }
  • UserDetailsService 상속받고 오버라이딩하기
public interface AccountService extends UserDetailsService {
  • loadUserByUsername 오버라이딩
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
	AccountVO account = accountDAO.selectAccountForAuth(username);
	if (account == null) {
		throw new UsernameNotFoundException(MessageFormat.format("{0} 사용자 없음", username));
	}
	// employee 정보 가져오기
	EmployeeVO empVO = employeeDAO.selectEmp(username);
	return new AccountWrapper<EmployeeVO>(account, empVO);
}
  • Authentication에서 authenticate() 수행후, 인증에 성공하면 CustomAuthenticationSuccessHandler로 이동
@Slf4j
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
		Authentication authentication) throws ServletException, IOException {
		
	// principal 객체를 가져와서 계정정보 읽기 (직원: EMP, 항공사: AL, 입점업체: VD)
	AccountWrapper aw = (AccountWrapper) authentication.getPrincipal();
	String accInfo = aw.getAccInfo();
		
	super.setDefaultTargetUrl("/in");
	super.onAuthenticationSuccess(request, response, authentication);
	}
}

마주한 고민

공항직원, 입점사, 항공사 계정을 하나의 계정테이블로 통합하여 관리하다보니, 각자의 다른 타입에 대하여 처리하기가 어려운 문제가 있었다.

전체를 아우르는 상위 클래스를 만들고 이를 상속하게 하여서 처리할 수 도 있지만, 이는 MyBatis 매핑을 따로 따로 설정해줘야 하는 번거로움이 있고, 각각의 클래스에서 구현한 개별 메서드가 필요한 경우도 있기 때문에 다른 방법을 생각해보았다.

따라서, User를 상속받고 제너릭 타입의 realUser와 로그인한 사용자가 어떠한 타입의 유저인지를 구분하기 위해서 String 타입의 accInfo를 두었다. 따라서 아래와 같이 getRealUser로 가져와서 타입에 맞게 형변환 할 수 있도록 설정하였다.

@GetMapping("/temp.do")
public ResponseEntity<?> myTempMethod(Authentication auth) {
		
	AccountWrapper wrapper = (AccountWrapper) auth.getPrincipal();
	String accInfo = wrapper.getAccInfo();
		
	if (accInfo.equals("EMP")) {
			
		EmployeeVO emp = (EmployeeVO) wrapper.getRealUser();

	} else if (accInfo.equals("VD")) {
			
		vendorVO vendor = (vendorVO) wrapper.getRealUser();
			
	} else { // AL
			
		AirlineVO airLine = (AirlineVO) wrapper.getRealUser();
			
	}
		
	return ResponseEntity.ok(null);
	
}

보너스 학습

session vs token

토큰 기반 인증은 세션 정보를 서버에 저장하지 않고, 클라이언트 측에 토큰을 저장하여 인증하는 방식입니다. 각 요청에서 서버에 전송되는 토큰을 사용하여 사용자의 인증 상태를 확인하고 처리합니다. 이러한 방식으로 인증을 처리함으로써 서버는 세션 상태를 유지할 필요 없이 사용자를 식별하고 인증할 수 있습니다.
주로 JWT(JSON Web Token)를 사용하여 토큰 기반 인증을 구현합니다. 사용자가 로그인을 하면 서버에서 JWT를 발급하고, 클라이언트는 이 JWT를 저장하여 인증된 요청을 보낼 때마다 HTTP 헤더에 포함시켜 서버로 전송합니다. 서버는 이 토큰을 검증하여 사용자를 인증하고 요청을 처리합니다.
토큰 기반 인증은 세션 기반 인증과 달리 서버에 상태를 유지하지 않기 때문에 분산 환경에서 더 용이하며, 확장성이 높습니다. 또한 클라이언트와 서버 간의 독립성이 높아져서 다양한 클라이언트 플랫폼과 기술 스택을 사용하는 애플리케이션에 적합합니다.

참고

Spring Security란?
Filter와 Interceptor
DelegatingFilterProxy
Spring Security 동작 원리 & 이미지 사용

0개의 댓글