본 포스트에서는 접근제어에 대하여 설명합니다. 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를 시작한지 얼마 안되어 딱히 어떤 접근제어가 적절한지는 아직 미정입니다.
글을 마치고 나서 수학능력시험 점수 노출에 대한 뉴스(수능 성적 조회 시스템, 이렇게 허술했나?…지난해에도 ‘지적’)가 나왔습니다. 본 포스트에서 살펴본 동적 접근제어를 적절하게 활용하면 뉴스에서 나온 성적유출과 관련된 보안이슈를 제거할 수 있습니다. 노출시작 시각과 만료시각을 데이터베이스를 통해 관리하고 동적으로 접근제어를 처리하는 로직에서 처리한다면 본 이슈는 해결이 가능합니다.
참고사이트
- Spring Security OAuth
https://projects.spring.io/spring-security-oauth/docs/oauth2.html - Spring Boot로 만드는 OAuth2 시스템 9
https://brunch.co.kr/@sbcoba/15 - [Spring/Security] 초보자가 이해하는 Spring Security
https://postitforhooney.tistory.com/entry/SpringSecurity-%EC%B4%88%EB%B3%B4%EC%9E%90%EA%B0%80-%EC%9D%B4%ED%95%B4%ED%95%98%EB%8A%94-Spring-Security-%ED%8D%BC%EC%98%B4 - PreAuthorize가 컨트롤러에서 작동하지 않습니다.
https://cnpnote.tistory.com/entry/SPRING-PreAuthorize%EA%B0%80-%EC%BB%A8%ED%8A%B8%EB%A1%A4%EB%9F%AC%EC%97%90%EC%84%9C-%EC%9E%91%EB%8F%99%ED%95%98%EC%A7%80-%EC%95%8A%EC%8A%B5%EB%8B%88%EB%8B%A4 - Spring Security Reference
https://docs.spring.io/spring-security/site/docs/3.2.5.RELEASE/reference/htmlsingle/#el-access - Spring-security authorizeRequest 동적으로 설정하기
https://soon-devblog.tistory.com/7
'Development > Spring Framework, Security, OAuth2' 카테고리의 다른 글
Spring Boot with OAuth2 - 8. OAuth2 Server - Client, User Cache (0) | 2019.12.03 |
---|---|
Spring Boot with OAuth2 - 7. JWT Refresh Token 관리 (0) | 2019.12.02 |
Spring Boot with OAuth2 - 5. OAuth2 Resource Server 분리 (0) | 2019.11.27 |
Spring Boot with OAuth2 - 4. OAuth2 Server 확장 3 - JwtTokenStore (0) | 2019.11.26 |
Spring Boot with OAuth2 - 3. OAuth2 Server 확장 2 - MyBatis + MariaDB 연결 (0) | 2019.11.21 |