상세 컨텐츠

본문 제목

Spring Boot Security - 접근 제어

개발

by 목장주 2018. 11. 10. 03:16

본문

Spring Boot에서 REST API를 공개용, 회원용, 관리자용으로 접근을 제한하고 싶다면 Spring Security를 이용하면 됩니다. 이번 글에서는 간단하게 Spring Security를 이용해서 권한 관리를 구현을 합니다. 아주 간단한 스프링 앱에서 시작해서 Spring Security를 적용하고, DB와 OAuth2로 점점 넓혀가보도록 하겠습니다.



기본 앱 생성

Spring Initializr에 가셔서 프로젝트를 하나 생성합니다. dependency는 web 하나만 추가합니다. maven이나 gradle 둘 중 아무거나 선택해도 상관없습니다. 생성된 소스는 github에 올려놨으니 참고 하셔도 됩니다. step-01 브랜치를 사용하시면 됩니다.


아무나 들어올 수 있는 메인 페이지("/"), 로그인 한 사람이 볼 수 있는 비공개 페이지("/private"), 관리자만 볼 수 있는 관리자 페이지("/admin")이 있다고 가정해 봅시다. 아주 간단하게 아래와 같이 MainController를 만듭니다. 



@RestController
public class MainController {

    @RequestMapping("/")
    public String handleHome() {
        return "Public page";
    }

    @RequestMapping("/private")
    public String handlePrivate() {
        return "Private page";
    }

    @RequestMapping("/admin")
    public String handleAdmin() {
        return "Admin page";
    }
}



로그인 여부에 따라 접근이 관리되는지 테스트하기 위해 테스트를 작성합니다. 



@RunWith(SpringRunner.class)
@WebMvcTest(MainController.class)
public class SpringSecurityApplicationTests {

	@Autowired
	private MockMvc mvc;

	@Test
	public void givenRequestOnPublicPage_shouldReturn200() throws Exception {
		mvc.perform(
				get("/")
		).andExpect(
				status().isOk()
		);
	}

	@Test
	public void givenRequestOnPrivateAndAdminPageWithoutLogin_shouldReturn401() throws Exception {
		mvc.perform(
				get("/private")
		).andExpect(
				status().isUnauthorized()
		);

		mvc.perform(
				get("/admin")
		).andExpect(
				status().isUnauthorized()
		);
	}
}



@WebMvcTest를 사용해서 Controller만 테스트 하도록 합니다. 보안이 적용되었다면 아무나 볼 수 있는 / 요청에 대해서는 200 코드를, /private과 /admin 요청에 대해서는 401 코드를 반환해야 합니다. 하지만 아직 API에 보안이 적용되어 있지 않기 때문에 모든 요청에 대해 200 코드를 반환합니다. 


givenRequestOnPrivateAndAdminPageWithoutLogin_shouldReturn401401 테스트는 요청에 대해 401 코드 반환을 기대했는데 200 코드를 받았기 때문에 테스트가 실패합니다.



java.lang.AssertionError: Status 
Expected :200
Actual   :401
...
at com.gomchol.springsecurity.SpringSecurityApplicationTests.
givenRequestOnPrivateAndAdminPageWithoutLogin_shouldReturn401
(SpringSecurityApplicationTests.java:34)



이제 테스트가 성공하도록 보안을 적용해 봅시다. 



Spring Security

Spring Security는 스프링을 위한 인증, 접근 관리 프레임웍입니다. gradle 설정에 아래처럼 의존성을 추가해 주면 사용이 가능합니다.



implementation('org.springframework.boot:spring-boot-starter-security')
testImplementation('org.springframework.security:spring-security-test')



의존성을 추가한 후 앱을 실행시켜봅시다. 브라우저를 열고 http://localhost:8080에 접속해보면 갑자기 로그인 화면(/login)으로 이동합니다. 로그인 화면을 만들지 않았는데 갑자기 생겨났습니다. 


spring-boot-starter-security 의존성을 추가하면 spring-security-web이 자동으로 추가가 됩니다. 이 안에 있는 DefaultLoginPageGenerationFilter에서 로그인 화면을 생성한 것입니다. 


Spring Security를 추가하면 기본적으로 모든 URL을 보호해 줍니다. 따라서 로그인 하지 않으면 /login으로 이동을 해서 로그인할 수 있도록 만들어 놨습니다.   


그러면 아이디와 비밀번호는 어디에 있을까요? Spring Security에서 제공하는 UserDetailsServiceAutoConfiguration에서 user라는 사용자를 만들어 놨습니다. 비밀번호는 매번 실행할 때마다 생성되도록 되어 있습니다. 실행 로그를 살펴보면 아래와 같은 2줄을 볼 수 있습니다.



2018-11-07 14:06:13.327  INFO 20191 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 
Using generated security password: fa8906b4-777e-4175-9983-2d0ea6307340



로그인 화면에 아이디는 user, 비밀번호는 스프링 로그에 있는 값을 복사해서 넣고 Sign In 버튼을 누르면 Public page라는 글자를 볼 수 있습니다. 


매번 실행 로그에서 비밀번호를 가져올 수 없으니 비밀번호를 간단하게 password로 바꿔봅시다. 


src/main/resources/application.properties를 열어서 다음과 같이 적어줍니다. 



spring.security.user.name=user
spring.security.user.password=password



다시 앱을 실행 시킨 다음 user와 password를 로그인 폼에 넣어주면 잘 작동 합니다.


이제 다시 테스트로 돌아와보죠. 테스트를 실행해보면 아까와는 반대의 상황이 벌어집니다. 



java.lang.AssertionError: Status 
Expected :200
Actual   :401
...
at com.gomchol.springsecurity.SpringSecurityApplicationTests.
givenRequestOnPublicPage_shouldReturn200
(SpringSecurityApplicationTests.java:25)


아까는 잘 되던 / 요청 테스트가 401코드를 반환 받으면서 실패합니다. 대신 /private, /admin은 예상대로 401 코드를 받아서 테스트가 성공을 합니다. 


Spring Security를 적용하면 모든 url에 인증이 필수가 됩니다. 인증 없이 서버에 요청을 하면 401 Unauthorized 상태 코드를 받게 됩니다. /는 인증 없이 모든 사람들이 볼 수 있게 해주고 싶기 때문에 Spring Security 설정을 바꿔줘야 합니다. 



WebSecurityConfigurerAdapter

URL에 따라 접근 관리 규칙을 바꾸려면 WebSecurityConfigurerAdapter를 상속받는 설정 클래스를 만들면 됩니다. WebSecurityConfig라는 클래스를 만들어 봅시다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/").permitAll()
                .anyRequest().authenticated()
                .and()
                .httpBasic();
    }
}

  • line 6: 접근 관리를 시작하겠다는 의미입니다.
  • line 7: / 요청에 대해 허락하라는 규칙입니다.
  • line 8: 그 외의 모든 요청에 대해 인증을 요구하는 규칙입니다.
  • line 10: BasicAuth를 사용하여 로그인을 요구하는 규칙입니다. 이전 규칙에는 로그인 폼으로 이동했지만, 이제는 팝업창이 하나 뜨면서 아이디와 비밀번호를 물어보게 됩니다.


이제 다시 테스트를 돌려보면 잘 통과합니다. 이제 인증이 되었다고 가정하고 /private과 /admin 페이지를 테스트 해보겠습니다. 아래처럼 2개의 테스트를 만들어 줍니다. 



@Test
@WithMockUser(value="fake-user")
public void givenRequestOnPrivatePageWithCredential_shouldReturn200() throws Exception {
	mvc.perform(
			get("/private")
	).andExpect(
			status().isOk()
	);
}

@Test
@WithMockUser(value="fake-user")
public void givenRequestOnAdminPageWithCredential_shouldReturn403() throws Exception {
	mvc.perform(
			get("/admin")
	).andExpect(
			status().isForbidden()
	);
}



이전 테스트와 비슷하지만 몇 가지 바뀐 것이 있습니다. line 2, 12에 @WithMockUser를 추가했습니다. 가상의 로그인 된 사용자 fake-user가 있다는 가정하에 /private과 /admin에 요청을 합니다. /private는 인증이 된 사용자라면 누구나 볼 수 있어야 하기에 200 코드를 받아야 합니다. /admin의 경우 인증된 사용자 중 관리자 권한이 있는 사람만 볼 수 있어야 합니다. 현재 fake-user는 관리자 권한이 없기에 서버는 403 Forbidden 코드를 돌려줘야 합니다. 


테스트를 돌려보면 관리자 페이지 요청에서 에러가 납니다. 403을 기대했지만 200을 받은 것입니다. 



java.lang.AssertionError: Status 
Expected :403
Actual   :200
...
at com.gomchol.springsecurity.SpringSecurityApplicationTests.
givenRequestOnAdminPageWithCredential_shouldReturn403
(SpringSecurityApplicationTests.java:74)



현재 WebSecurity 설정에서는 로그인만 되면 다 받아주도록 되어있습니다. 이제 권한을 추가해봅시다. WebSecurityConfig의 규칙을 다음과 같이 수정해 줍니다. 



@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin").hasRole("ADMIN")
                .antMatchers("/private").hasRole("USER")
                .antMatchers("/").permitAll()
                .anyRequest().authenticated()
                .and()
                .httpBasic();
    }
}

  1. line 7: /admin은 ADMIN 권한이 있는 사람만 접근이 가능한 규칙입니다. 
  2. line 8: /private은 USER권한이 있는 사람만 접근이 가능한 규칙입니다.  


규칙을 설정할 때는 순서와 표현식이 중요합니다. 규칙을 평가할 때 위에서부터 순서대로 평가를 합니다. 따라서 가장 세세한 규칙이나 예외 규칙이 먼저오고, 가장 포괄적인 규칙이 마지막에 오도록 적어야 합니다. 


아래의 코드는 /admin 밑의 url은 ADMIN 권한을 가진 사용자만 허용합니다. /** 표현식은 /밑의 모든 url을 나타냅니다. 즉, /admin에 대한 규칙은 위에서 예외로 처리 했으니 그 외의 모든 /로 시작하는 url은 로그인만 하면 허용하는 규칙입니다. 



http.authorizeRequests()
    .antMatchers("/admin/**").hasRole("ADMIN")
    .antMatchers("/**").hasRole("USER");



같은 규칙이지만 순서가 바뀌면 전혀 다른 이야기가 됩니다. 아래의 코드를 실행하게 되면 /admin 요청을 받았다고 해도, /** 표현식이 /admin을 포함하기 때문에 첫 번째 규칙에 해당합니다. 따라서 로그인 된 사용자라면 /admin 페이지에 접근이 가능합니다. 두 번째 규칙으로 내려오기 전에 switch 문법처럼 이전 규칙에서 걸러지면 접근 관리가 끝납니다. 



 http.authorizeRequests()
    .antMatchers("/**").hasRole("USER")
    .antMatchers("/admin/**").hasRole("ADMIN");



이제 테스트를 다시 돌려보면 문제 없이 잘 작동합니다.


ADMIN권한을 주고 테스트 해보겠습니다. 아래와 같이 테스트를 하나 더 추가해 줍니다. 



@Test
@WithMockUser(value="fake-user", roles = "ADMIN")
public void givenRequestOnAdminPageWithAdminRole_shouldReturn200() throws Exception {
	mvc.perform(
			get("/admin")
	).andExpect(
			status().isOk()
	);
}



line 2에서 이전 테스트에는 없던 roles가 추가되었습니다. fake-user라는 사용자는 ADMIN 권한이 있다고 가정하고 /admin을 요청해 보는 것입니다. 테스트는 문제 없이 통과합니다.



관련글 더보기