본문으로 바로가기

728x90

본 포스트에서는 접근제어에 대하여 설명합니다. OAuth2에서는 권한관리를 포함합니다. Client의 Scope와 Authority, User의 Authority를 통하여 접근제어가 가능합니다.

 

접근제어를 사용하기 표현식을 사용하는 방법은 다음을 참고하십시오.

(https://docs.spring.io/spring-security/site/docs/3.0.x/reference/el-access.html#el-common-built-in)

  • hasRole([role]) : 현재 사용자의 권한이 파라미터의 권한과 동일한 경우
  • hasAnyRole([role1,role2]) : 현재 사용자의 권한디 파라미터의 권한 중 일치하는 것이 있는 경우
  • principal : 사용자를 증명하는 주요객체(User)를 직접 접근할 수 있다.
  • authentication : SecurityContext에 있는 authentication 객체에 접근 할 수 있다.
  • permitAll : 모든 접근 허용
  • denyAll : 모든 접근 비허용
  • isAnonymous() : 현재 사용자가 익명(비로그인)인 상태인 경우 true
  • isRememberMe() : 현재 사용자가 RememberMe 사용자라면 true
  • isAuthenticated() : 현재 사용자가 익명이 아니라면 (로그인 상태라면) true
  • isFullyAuthenticated() : 현재 사용자가 익명이거나 RememberMe 사용자가 아니라면 true

 

Client 접근제어

Client's Scope 기반 접근제어

HttpSecurity(ResourceServerConfig / WebSecurityConfig) 레벨 접근제어

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
	@Override
	public void configure(HttpSecurity http) throws Exception {
		http
			.authorizeRequests().antMatchers("/v1/api/**").access("#oauth2.hasScope('read_profile')")
			.and()
			.authorizeRequests().antMatchers("/v2/api/**").access("#oauth2.hasAnyScope('read_profile', 'write_profile')")
			.and()
			.authorizeRequests().anyRequest().authenticated()
			.requestMatchers().fullyAuthenticated()
			;
	}
}

위 코드에서 순서대로 접근제어를 수행합니다. 즉, 위 구문에서 3번째 구문은 앞 2개의 접근제어를 포함하는 관계로 먼저 선언되어 있을 경우에는 나머지 2개는 접근제어를 할 수 없게 됩니다. (명확한 스펙은 확인해보지 않아 틀릴 수 있으나 순서를 바꾸어가면서 테스트해본 결과 선언된 순서에 따라 접근제어가 처리되는 것을 확인하였습니다.)

 

Service Controller 레벨 접근제어

@PreAuthorize, @PostAuthorize 사용전에 ResourceServerConfig 객체 또는 WebSecurityConfig 객체 상단에 @EnableGlobalMethodSecurity(prePostEnabled = true) 가 선언되어 있어야 접근제어가 가능합니다.

@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
}

@RestController
public class RestResourceSample {
	@PreAuthorize("#oauth2.hasScope('read_profile')")
	@RequestMapping(value="/api/users", method=RequestMethod.GET)
	public String  profile1() {
	}
    
	@PreAuthorize("#oauth2.hasAnyScope('read_profile', 'write_profile')")
	@RequestMapping(value="/api/users", method=RequestMethod.GET)
	public String  profile2() {
	}
}

 

Client's Authority 기반 접근제어

AuthorizationServerSecurityCongiturer 레벨 접근제어

AuthorizationServerConfig 에서 접근제어하는 방법입니다. AccessToken과 Check Token을 제어하는 항목에 Client의 Authority를 사용하였습니다. 지난 포스팅의 ResourceServer 분리한 상태에서 Resource Server가 Authorization Server에 Token에 대한 유효성을 확인하는 과정에서 설정할 수 있는 접근제어 방식입니다. 신뢰할 수 있는 Client만 Token을 확인 할 수 있도록 합니다. (개인적인 프로젝트에서 아래의 방법을 테스트해보지는 않았습니다. 아직까지는 아래의 접근제어를 사용할 만한 상황 또는 프로젝트가 아닙니다.)

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
	private static final Logger log = LoggerFactory.getLogger(AuthorizationServerConfig.class);

	@Override
	public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
		security
			.tokenKeyAccess("isAnonymous() || hasAuthority('ROLE_TRUSTED_CLIENT')")
			.checkTokenAccess("hasAuthority('ROLE_TRUSTED_CLIENT')");
		
//		security.tokenKeyAccess("permitAll()")
//			.checkTokenAccess("isAuthenticated()") //allow check token
//			.allowFormAuthenticationForClients();
//	}
}

자세한 사항은 아래 링크를 참고하십시오.

https://projects.spring.io/spring-security-oauth/docs/oauth2.html#resource-server-configuration

 

HttpSecurity와 Service Controller 레벨에서는 Client의 Authority를 통해 접근제어하는 방법을 찾을 수 없었습니다. 방법이 있다면 댓글이나 피드백 부탁드립니다.

 

User 접근제어

User's Authority 기반 접근제어

HttpSecurity(ResourceServerConfig / WebSecurityConfig) 레벨 접근제어

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
	private static final Logger log = LoggerFactory.getLogger(ResourceServerConfig.class);
	
	@Override
	public void configure(HttpSecurity http) throws Exception {
//		super.configure(http);
		http
//			.requestMatchers().antMatchers("/api/**")
//			.and()
			.authorizeRequests().antMatchers("/api/**").access("#oauth2.hasScope('read_profile')")
			.and()
			.authorizeRequests().anyRequest().authenticated()
//			.and()
			.requestMatchers().fullyAuthenticated()
			;
		/*
		http
			.authorizeRequests().antMatchers("/api/**").hasAnyAuthority("ROLE_USER", "admin")
			.and()
			.authorizeRequests().antMatchers("/api/**").access("#oauth2.hasScope('read_profile')")
//			.access("hasAuthority('ROLE_USER') || hasAuthority('admin') || #oauth2.hasScope('read_profile')")
			.anyRequest().fullyAuthenticated()
			.and()
			.exceptionHandling().authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED));
		
		http
			.authorizeRequests().antMatchers("/api/**").access("hasAuthority('ROLE_USER') || hasAuthority('admin') || #oauth2.hasScope('read_profile')")
			.anyRequest().fullyAuthenticated()
			.and()
			.exceptionHandling().authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED));

		http
			.authorizeRequests().antMatchers("/login").permitAll()
			.and()
			// default protection for all resources (including /oauth/authorize)
			.authorizeRequests().anyRequest().hasRole("USER");
            
         
		http
			.csrf().disable()
			.authorizeRequests()
				.antMatchers("/", "/main").permitAll()
				.anyRequest().authenticated()
				.and()
			.formLogin()
				.loginPage("/login")
				.usernameParameter("userId") 
				.successHandler(new LoginSuccessHandler("/home")) 
				.permitAll()
				.and()
			.logout()
				.invalidateHttpSession(true)
				.deleteCookies("JSESSIONID")
				.permitAll()
				.and()
			.authenticationProvider(authProvider)
			.exceptionHandling().accessDeniedHandler(accessDeniedHandler());
		*/
	}
}

Service Controller 레벨 접근제어

@RestController
public class RestResourceSample {
//	@Secured("ROLE_ADMIN")
//	@Secured({"ROLE_USER","ROLE_ADMIN"})
//	@PreAuthorize("#oauth2.hasScope('read_profile')")
//	@PreAuthorize("hasAuthority('ROLE_USER')")
//	@PreAuthorize("hasAnyAuthority('ROLE_USER', 'user')")
//	@PreAuthorize("hasAnyRole('ROLE_USER', 'user')")
	@PreAuthorize("hasRole('ROLE_USER')")
//	@PostAuthorize("isAuthenticated() and hasRole('ROLE_ADMIN')")
	@RequestMapping(value="/api/users", method=RequestMethod.GET)
	public String  profile() {
		// ...... do something...
	}
}

 

HttpSecurity 동적 접근제어

위에서 언급한 접근제어 방식은 소스코드상에 하드코딩된 상태로 동적인 접근권한 설정을 할 수가 없습니다. 권한을 수정하기 위해서 소스코드를 수정하는 것도 무리이며 수정코드의 반영을 위하여 서버를 재기동하는 것도 운영상에 문제를 발생시킵니다.

이 장에서는 데이터베이스를 활용하여 동적으로 접근제어하는 방법에 대해서 설명합니다.

 

아래 예제에서 구현하는 동적 접근제어 방법은 실제 프로젝트 사용하기에는 몇가지 문제가 있습니다.

첫번째로 모든 접근url에 대해서 데이터베이스에 접속합니다. 따라서, 데이터베이스에는 동일한 내용을 지속적으로 조회하게 되어 불필요한 부하를 유발하게 됩니다. 동적으로 url에 대한 접근제어를 한다고 하여도 실제 접근제어가 변경되는 시점을 아주 적습니다. 이는 초기화시에 url 접근제어 항목을 읽어와서 메모리에 저장하는 Cache처리하는 방법으로 변경되어야 합니다. Timer 또는 Event, Spring Cache Abstraction을 이용하여 Cache를 업데이트하는 방법으로 처리하는 방안도 고려되어야 합니다.

두번째로 접근제어하는 항목의 순서가 중요합니다. 위에서도 거론되었지만 먼저 수행하는 url이 정합성에 부합되면 나중에 정의된 접근제어는 무시하게 됩니다. 따라서, 이 예제에서는 빠져있지만 처리되는 url의 적절한 순서제어가 필요합니다. 상세url이 먼저 검증되어야 합니다.

Model 객체 구현

아래는 간단하게 url과 authority만을 사용하여 접근제어하는 방법입니다. 실무에서는 Client's Scope 속성 등을 추가하여 구현해야 합니다. Authority와 Scope의 배열 등의 처리는 이 예제에서는 사용하지 않습니다.

public class SecurityMatcher {
 
    private String url;
    private String authority;
    
    public String getUrl() {
        return url;
    }
    public void setUrl(String url) {
        this.url = url;
    }
    public String getAuthority() {
        return authority;
    }
    public void setAuthority(String authority) {
        this.authority = authority;
    }
}

HttpSecurity 접근제어 수정

하드코딩된 접근제어 수행전에 validateAuthority() 함수를 지정하여 동적 접근제어를 먼저 수행합니다. 하드코딩으로 동작하는 코드들은 동적인 제어가 없는 로직일 경우에만 적용합니다.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
         validateAuthority(http);
         
         http
            .csrf().disable()
            .authorizeRequests()
                .antMatchers("/", "/main").permitAll()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login")
                .usernameParameter("userId") 
                .successHandler(new LoginSuccessHandler("/home")) 
                .permitAll()
                .and()
            .logout()
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
                .permitAll()
                .and()
            .authenticationProvider(authProvider)
            .exceptionHandling().accessDeniedHandler(accessDeniedHandler());
    }
 
    private void validateAuthority(HttpSecurity http) throws Exception {
        List<SecurityMatcher> urlMatchers = repository.findAll();
         for (SecurityMatcher matcher : urlMatchers) {
//             http
//                 .authorizeRequests()
//                 .antMatchers(matcher.getUrl()).hasAuthority(matcher.getAuthority());
             http
                 .authorizeRequests()
                 .antMatchers(matcher.getUrl()).access(matcher.getAuthority());
         }
    }

HttpSecurity 접근제어 확장

.access() 함수를 사용하여 접근하는 방법에 대해서 설명합니다.

    @Autowired
    private AuthorizationChecker authorizationChecker;
    
    @Bean
    public AuthorizationChecker authorizationChecker() {
    	return new AuthorizationChecker();
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
         matchUrlAndAuthority(http);
         http
             .authorizeRequests()
             .antMatchers("/", "/main","/accessDenied").permitAll()
             .anyRequest().access("@authorizationChecker.check(request, authentication)")
             .and()
    }
    

AuthorizationChecker 객체 구현

@Component
public class AuthorizationChecker {
    @Autowired
    private SecurityUrlMatcherRepository urlMatcherRepository;
 
    @Autowired
    private UserRepository userRepository;
 
    public boolean check(HttpServletRequest request, Authentication authentication) {
        Object principalObj = authentication.getPrincipal();
 
        if (!(principalObj instanceof User)) {
            return false;
        }
 
        String authority = null;
        for (SecurityUrlMatcher matcher : urlMatcherRepository.findAll()) {
            if (new AntPathMatcher().match(matcher.getUrl(), request.getRequestURI())) {
                authority = matcher.getAuthority();
                break;
            }
        }
 
        String userId = ((User) authentication.getPrincipal()).getUserId();
        User loggedUser = userRepository.findByUserId(userId);
 
        List<String> authorities = loggedUser.getAuthority();
 
        if (authority == null || !authorities.contains(authority)) {
            return false;
        }
        return true;
    }
}

 

Service Controller 레벨 접근제어 응용

변수값 참조 방법

접근하려는 User 정보가 로그인한 사용자 본인이거나 관리자 권한을 가진 사용자라면 접근을 허용해주는 예는 다음과 같습니다. @PreAuthorize와 @PostAuthorize의 차이는 언제 접근권한을 체크할 건지에 따라 달라집니다. @PreAuthorize는 함수 실행 이전에 체크하고 @PostAuthorize는 함수 실행 이후에 체크합니다.

@RestController
public class RestResourceSample {
	// 반환값(User객체)에서 name값이 로그인 사용자이름이거나 관리자권한이라면
	@PostAuthorize("isAuthenticated() and (( returnObject.name == principal.name ) or hasRole('ROLE_ADMIN'))")
	@RequestMapping(value="/api/user1", method=RequestMethod.GET)
	public User user1() {
		// Build some dummy data to return for testing
		Authentication auth = SecurityContextHolder.getContext().getAuthentication();
		Object principal = auth.getPrincipal();
		String username = (String) principal;
		
		User user = dao.findUserByName(username);
		return user;
	}

	// 파라메터의 User객체에서 name값이 로그인한 이름이거나 관리자 권한이라면
	@PreAuthorize("isAuthenticated() and (( #user.name == principal.name ) or hasRole('ROLE_ADMIN'))")
	@RequestMapping(value="/api/user2", method=RequestMethod.GET)
	public String user2(User user) {
		// ...... do something...
	}
}

접근제어 값 살펴보기

@Controller
public class RestResourceSample {
	@ResponseBody
	public String  profile() {
		// Build some dummy data to return for testing
		Authentication auth = SecurityContextHolder.getContext().getAuthentication();
		Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
		
		Object principal = auth.getPrincipal();
		log.debug("Principal : {}", principal);				// Principal : admin
		log.debug("Authorities : {}", authorities);			// Authorities : [user, admin]
		if(auth instanceof OAuth2Authentication) {
			Set<String> scops = ((OAuth2Authentication) auth).getOAuth2Request().getScope();
			log.debug("Scopes : {}", scops);				// Scopes : [read_profile]
		}
        
		// ...... do something...
	}
}

 

글을 마치며

ResouceServerConfig/WebSecurityConfig의 HttpSecurity와 Service Controller에서의 접근제어가 동시에 사용 가능합니다. 개인적으로는 HttpSecurity레벨에서는 Client's Scope와 User's Authority로 동작하고 Service Controller레벨에서는 User's Authority의 상세 접근제어로 분리하는 것이 관리적인 차원에서는 좋을 것입니다.

글을 쓰는 현 시점(2019년 11월 28일)에서는 Spring Security OAuth2를 시작한지 얼마 안되어 딱히 어떤 접근제어가 적절한지는 아직 미정입니다.

글을 마치고 나서 수학능력시험 점수 노출에 대한 뉴스(수능 성적 조회 시스템, 이렇게 허술했나?…지난해에도 ‘지적’)가 나왔습니다. 본 포스트에서 살펴본 동적 접근제어를 적절하게 활용하면 뉴스에서 나온 성적유출과 관련된 보안이슈를 제거할 수 있습니다. 노출시작 시각과 만료시각을 데이터베이스를 통해 관리하고 동적으로 접근제어를 처리하는 로직에서 처리한다면 본 이슈는 해결이 가능합니다.

 

참고사이트

 

728x90