반응형

스프링 시큐리티와 앵귤러JS Spring Security and AngularJS


Part I : 단일 페이지 보안 어플리케이션 (A Secure Single Page Application)

Part II: 로그인페이지 (The Login Page)

Part III: 리소스 서버 (The Resource Server)

Part IV: API 게이트웨이 (The API Gateway)

Part V: OAuth2와 싱글사인온 (Single Sign On with OAuth2)

Part VI: 다중 UI 어플리케이션과 게이트웨이 (Multiple UI Applications and a Gateway)

Part VII: 모듈화한 AngularJS 어플리케이션  (Modular AngularJS Application)

Part VIII: Angular 어플리케이션 테스트 (Testing an Angular Application)


--------------------------------------------------------------------------------

리소스 서버 The Resource Server

이 섹션에서 우리는 어떻게 스프링 시큐티리와 앵귤러JS로 단일 페이지 어플리케이션을 만드는지 계속 얘기해볼 것이다. 우리의 어플리케이션에서 동적 컨텐트로서 사용하고 있는 "greeting" 리소스를 별도의 서버 (먼저 보호하지않아도 되는 리소스, 그다음 토큰에 의해 보호되는 리소스)로 빼내는 걸로 시작하자. 이 글은 시리즈의 3번째 섹션이다. 당신이 어플리케이션의 기본구성을 이해하거나 처음부터 빌드해보려고 하면 첫번째 섹션부터 읽도록 하자 또는 보호하지않아도 되는 리소스토큰에 의해 보호되는 리소스 두 파트로 나누어진 Github의 소스코드를 직접 받아도 된다. 


기억하기: 샘플 어플리케이션으로 이 섹션 동안 돌릴 거면, 쿠키와 HTTP Basic credentials의 브라우저 캐시를 꼭 지워야한다. 크롬에서는 새 익명창incognito window로 여는게 최선의 방법이다.

분리된 리소스 서버 A Separate Resource Server

클라이언트쪽 수정 Client Side Changes

클라이언트 쪽은 리소스를 서로 다른 백엔드로 이동하기위해 해줘야 할게 거의 없다. 여기 "home" 컨트롤러의 마지막 섹션을 보자:

hello.js

angular.module('hello', [ 'ngRoute' ])
...
.controller('home', function($scope, $http) {
	$http.get('/resource/').success(function(data) {
		$scope.greeting = data;
	})
})
...

우리가 해줘야하는 건 URL을 바꿔주는 게 전부다. 예를 들면, 로컬호스트의 새 리소스를 돌리려면 아래와 같이 하면 된다:

hello.js
angular.module('hello', [ 'ngRoute' ])
...
.controller('home', function($scope, $http) {
	$http.get('http://localhost:9000/').success(function(data) {
		$scope.greeting = data;
	})
})
...

서버쪽 수정 Server Side Changes

 UI 서버의 수정은 아주 경미하다. 그냥 (기존에 "/resource" 였던) greeting 리소스를 위한 @RequestMapping를 지워주면 된다. 그 다음 우리가 Spring Boot Initializr를 사용한 첫번째 섹션에서 했던 대로 새로운 리소스 서버를 만들어줘야한다. 예를 들면 유닉스 계열의 시스템에선 다음과 같이 curl을 사용해보자: [주: 그냥 sts씁시다]

$ mkdir resource && cd resource
$ curl https://start.spring.io/starter.tgz -d style=web \
-d name=resource -d language=groovy | tar -xzvf -

그후 프로젝트를 당신이 선호하는 IDE에 import 하자 (기본설정은 보통 메이븐 자바 프로젝트다), 또는 커맨드라인에서 "mvn"을 써도 된다. 우리는 여기서 그루비를 사용할테지만 자바가 편하다면 자바를 써도 된다, 어쨋든 코드가 많지 않을 것이다.

예전 UI에 구현해둔 코드를 복사해서 main application class에 @RequestMapping 를 추가하자:

ResourceApplication.groovy
@SpringBootApplication
@RestController
class ResourceApplication {

  @RequestMapping('/')
  def home() {
    [id: UUID.randomUUID().toString(), content: 'Hello World']
  }comm

  static void main(String[] args) {
    SpringApplication.run ResourceApplication, args
  }

}

일단 이걸로 다음의 커맨드라인을 타입함으로서 어플리케이션을 브라우저에서 불러올 수 있다. 

$ mvn spring-boot:run --server.port=9000

브라우저에서 http://localhost:9000 를 열고 geeting을 가진 JSON을 확인해보자. ("src/main/resources"에 위치한) application.properties에서 포트넘버를 바꿀 수 있다.

application.properties
server.port: 9000

브라우저를 열고  8080 포트에서 작동중인) UI로 부터 리소스를 불러보면 제대로 동작하지않는 것을 볼 수 있다. 브라우저가 XHR요청을 허용하지 않기 때문이다.

CORS 협상 CORS Negotiation

브라우저는 Cross Origin Resource Sharing 프로토콜에 따라 접근이 허용되는지를 알기위해 우리의 리소스 서버에 협상을 한다. 이는 앵귤러JS의 소관이 아니다. 쿠키와 같이 브라우저에서 그냥 모든 자바스크립트와 같이 작동할 것이다.  이 두 서버는 자기들이 공통의 기원origin을 가지고 있다고 선언하지 않았기 때문에 브라우저는 요청을 보내는 것을 거부하고 UI가 깨지게 된다.

이것을 고치려면 우리는 "pre-flight" 옵션 요청과 호출자의 허용된 행동을 리스트하기위한 몇몇의 헤더들을 포함하는 CORS 프로토콜을 지원해야한다. 스프링 4.2는 잘 정제된 CORS 지원을 한다고 했지만 릴리즈 전까지는 (주, 이미 릴리즈됨Filter.를 써서 모든 요청에 같은 CORS응답을 보내는 어플리케이션의 목적에 맞춰주는 작업을 해야한다. 리소스 서버 어플리케이션의 같은 디렉토리에 클래스를 하나 만들고 @Component 어노테이션을 한다 (스프링 어플리케이션 컨텍스트가 스캔할 수 있도록). 예를 들면:

CorsFilter.groovy

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class CorsFilter implements Filter {

  void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
    HttpServletResponse response = (HttpServletResponse) res
    HttpServletRequest request = (HttpServletRequest) req
    response.setHeader("Access-Control-Allow-Origin", "*")
    response.setHeader("Access-Control-Allow-Methods", "POST, PUT, GET, OPTIONS, DELETE")
    response.setHeader("Access-Control-Allow-Headers", "x-requested-with")
    response.setHeader("Access-Control-Max-Age", "3600")
    if (request.getMethod()!='OPTIONS') {
      chain.doFilter(req, res)
    } else {
    }
  }

  void init(FilterConfig filterConfig) {}

  void destroy() {}

}

Filter는 주요 스프링 시큐리티 필터보다 전에 적용되어야 하므로 @Order를 정의했다. 이 리소스 서버의 수정후, 재시작하면 UI로 greeting을 받아올 수 있다.

 태평스럽게  Access-Control-Allow-Origin=*를 사용하는건 빠르고 지저분하지만 잘 동작한다. 이 방식은 안전하지 않으므로 추천되는 방법은 아니다.

리소스 서버 보호하기 Securing the Resource Server

훌륭하게 새 아키텍처의 어플리케이션이 잘 동작하게 만들었다. 문제는 리소스서버가 보호되고 있지않다는 점 뿐이다.

스프링 시큐리티 추가하기 Adding Spring Security

UI서버와 같이 필터를 사용해서 리소스 서버에 시큐리티를 추가하는 법을 보자. 이것은 아마 더 기본의 방식대로 일것이고 대부분의 PaaS환경에서 최고의 선택이다. (보통 어플리케이션을 위해 private network를 만들지 않기때문에). 첫걸음은 정말 쉽다: 그냥 스프링 시큐리티를 메이븐 POM의 클래스패스에 추가해주면 된다:

pom.xml

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
  ...
</dependencies>

리소스 서버를 재시작해보자. 유후! 이제 보안이 적용되었다:

$ curl -v localhost:9000
< HTTP/1.1 302 Found
< Location: http://localhost:9000/login
...

로그인 페이지로 리다이렉트되었다. curl은 우리의 앵귤러 클라이언트처럼 헤더를 보내지 않기 때문이다. 더 유사한 헤더를 보내도록 이 명령어를 수정해보자:

$ curl -v -H "Accept: application/json" \
    -H "X-Requested-With: XMLHttpRequest" localhost:9000
< HTTP/1.1 401 Unauthorized
...

우리는 단지 모든 요청에 credential을 보내도록 클라이언트에 알려주기만 하면 된다.

토큰 인증 Token Authentication

인터넷과 사람들의 스프링 백엔드 프로젝트는 커스텀 토큰 기반 인증 솔루션들로 어지럽혀져있다. 스프링 시큐리티는 사용자 스스로 시작할 수 있는 그 근간의 Filter 구현체를 제공한다.( 예로, AbstractPreAuthenticatedProcessingFilter 와TokenService를 보자). 그렇지만 스프링 시큐리티는 쓰라고 정해놓은 구현체가 없다. 이 이유중 하나는 아마 더 손쉬운 방법이 있기때문일 것이다.

이 시리지의 두번째 섹션을 기억해보자. 스프링 시큐리티는 기본값으로 인증데이터를 저장하기위해 HttpSession 을 사용한다. 비록 이것은 세션과 직접적으로 연관이 없지만: 사용자가 뒷단의 저장장소를 변경하는 중에 사용할 수 있는 추상 레이어(SecurityContextRepository)가 있다. UI에 의해 검증된 인증을 저장하기 위해 우리의 리소스 서버에 이 repository를 설정해두면, 우리는 두 서버간에 인증을 공유하는데 쓸 수 있다. UI서버는 이미 (HttpSession를 통해) 그러한 저장을 하고 있으므로 이 저장된 것을 배포하고 리소스 서버에 따라 열면 우리는 대부분의 해결책을 갖추게된다. 

스프링 세션 Spring Session

스프링 세션으로 이 부분을 손쉽게 해결할 수 있다. 우리는 그저 공유데이터 저장소가 필요할 뿐이다. (레디스는 바로 사용가능도록 지원되고 있다). Filter를 설정하기위해 서버에 몇줄의 설정만 해주면 될뿐이다.

UI 어플리케이션에선 POM에 몇개의 의존성만 추가해주면 된다:

pom.xml
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session</artifactId>
  <version>1.0.0.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-redis</artifactId>
</dependency>

그 후 메인 어플리케이션에 @EnableRedisHttpSession 를 추가하자:

UiApplication.java
@SpringBootApplication
@RestController
@EnableRedisHttpSession
public class UiApplication {

  public static void main(String[] args) {
    SpringApplication.run(UiApplication.class, args);
  }

  ...

}

@EnableRedisHttpSession 어노테이션은 스프링 세션에서 온 것으로 스프링 부트의 레디스 연결을 지원한다. (URL과 credential은 환경변수 또는 설정 파일을 통해 설정할 수 있다)

위의 한줄의 코드로 레디스 서버는 UI어플리케이션을 실행하는 로컬호스트에서 동작하여 유효한 사용자 credential과 세션 데이터(인증과 CSRF토큰)를 가지고 로그인정보가 레디스에 저장될 것이다.


로컬에서 동작하는 레디스 서버가 없다면, Docker(윈도우와 맥에서는 VM이 필요하다)로 쉽게 돌릴 수 있다. Github의 소스코드에서 docker-compose.yml 파일이 있어 docker-compose up명령어로 커맨드라인에서 손쉽게 동작시킬 수 있다. 

UI로부터 커스텀 토큰 보내기 Sending a Custom Token from the UI

이제 하나만 빼고 완성되었는데 저장소에서 데이터에의 키값을 위한 전송 메카니즘이다. 키값은 HttpSession ID 이므로, 우리가 UI클라이언트에서 그 키값을 유지하고 있다 리소스 서버에 커스텀 헤더로서 이것을 보낼 수 있다. 따라서 "home" 컨트롤러는 greeting 리소스를 위해 HTTP 요청의 일부로서 이 헤더를 보낼 수 있도록 수정되어야 한다. 예를 들면:

hello.js

angular.module('hello', [ 'ngRoute' ])
...
.controller('home', function($scope, $http) {
  $http.get('token').success(function(token) {
    $http({
      url : 'http://localhost:9000',
      method : 'GET',
      headers : {
        'X-Auth-Token' : token.token
      }
    }).success(function(data) {
      $scope.greeting = data;
    });
  })
});

(더 우아한 해결책은 아마 필요할 때 토큰을 가져오는 것일 것이다. 앵귤러의 interceptor 를 사용하여 리소스 서버로의 모든 요청에 헤더를 추가 할 수 있다. 인터셉터의 정의는 한 장소에서 모든것을 하고 비지니스 로직을 마구 채워가는 대신 추상화하는 것이다.

"http://localhost:9000[http://localhost:9000]"에 직접적으로 가는 대신, 우리는 하나의 호출의 성공적인 콜백안에 UI서버의 "/token"이라는 새로운 커스텀 종단을 부르도록 씌우는 것(wrap)이다. 이것의 구현은 이렇게 사소하다:

UiApplication.java
@SpringBootApplication
@RestController
@EnableRedisHttpSession
public class UiApplication {

  public static void main(String[] args) {
    SpringApplication.run(UiApplication.class, args);
  }

  ...

  @RequestMapping("/token")
  @ResponseBody
  public Map<String,String> token(HttpSession session) {
    return Collections.singletonMap("token", session.getId());
  }

}

이렇게 UI어플리케이션은 준비가 되었고 모든 백엔드로의 호출에 "X-Auth-Token"이 헤더에 들어간 세션ID를 포함할게 될 것이다.

리소스 서버의 인증 Authentication in the Resource Server

커스텀 헤더를 받아들이려면 리소스 서버에 아주 작은 수정만 해주면 된다. CORS 필터에 원격 클라이언트로부터 허용되는 헤더를 정해줘야한다

예를 들면:

CorsFilter.groovy

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorsFilter implements Filter {

  void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
    ...
    response.setHeader("Access-Control-Allow-Headers", "x-auth-token, x-requested-with")
    ...
  }

  ...
}

이제 남은 작업은 리소스 서버에서 커스텀 토큰을 가져와 사용자를 인증하는데 이를 사용하는 것이다. 이 작업은 매우 직관적인데, 우리가 해줄 건 단지 어디에 세션 repository가 있는가, 그리고 들어오는 요청의 어디에서서 토큰(세션ID)를 찾을 수 있는가를 스프링 시큐리티에 알려주는 것 뿐이다. 먼저 우리는 스프링 세션과 레디스 의존성을 추가해줘야하고 그 후 Filter를 설정할 수 있다:

ResourceApplication.groovy
@SpringBootApplication
@RestController
@EnableRedisHttpSession
class ResourceApplication {

  ...

  @Bean
  HeaderHttpSessionStrategy sessionStrategy() {
    new HeaderHttpSessionStrategy();
  }

}

이 생성된 필터는 UI서버의 하나를 미러링해서 세션저장소로서 레디스를 설정한다. 유일한 차이점은 기본값("JSESSIONID"라 부르는 쿠키) 대신 이 헤더안에 위치한 커스텀 HttpSessionStrategy를 사용하는 것이다. 우리는 또한 인증되지 않은 사용자에게 보여지는 팝업창을 막아줘야한다. - 어플리케이션은 보호되고 있기때문에 기본값으로 WWW-Authenticate: Basic 를 가진 401를 보내 브라우저가 사용자명과 패스워드를 타입할 수 있는 팝업창으로 응답한다. 여러 가지 방법이 있지만 우리는 이미 앵귤러가 "X-Requested-With" 헤더를 보내도록 만들어뒀기때문에 스프링 시큐리티는 기본적으로 이를 처리해준다.

리소스 서버를 새로운 인증스키마에서 작동하게 만드는 마지막 수정이 남았다. 스프링 부트의 기본 시큐리티는 stateless이고 우리는 이를 세션안에서 인증을 저장하기를 원하므로 application.yml (또는 application.properties)에서 다음과 같이 명시해줘야한다:

application.yml

security:
  sessions: NEVER

이것은 스프링 시큐리티에게 "세션을 절대 생성하지 말되 거기에 이미 있다면 그것을 사용하라"는 뜻이다 (UI에서의 인증때문에 이미 거기에 있을 것이다.)

리소스 서버를 재시작하고 새 브라우저 창에서 UI를 열어보자

왜 쿠키와는 작동하지 않나요? Why Doesn’t it All Work With Cookies?

우리는 커스텀 헤더를 사용하고 헤더를 넣기위해 클라이언트의 코드를 만들었다. 이것은 끔찍하게 복잡하진 않으나 가능한한 쿠키와 세션을 사용하라는 두번째 섹션의 내용과 모순되는 것처럼 보인다. 불필요한 복잡성을 추가하려고 이걸 언급한게 아니다 우리가 지금 가지고 있는 구현체는 이미 지금까지 봐온 최고의 복잡성을 가졌다는 걸 확인하려는거다: 우리 솔루션의 기술적인 부분은 (명확히 아주 작고 사소한) 비지니스 로직보다 훨씬 더 가치가 있다. 이는 명백히 정당한 비판이다. (그리고 우리가 이 시리즈의 다음 섹션에서 언급할 것이다.) 그러나 왜 모든 것에 쿠키와 세션을 사용하는 것만큼 간단하지 않은지 잠시 둘러보자.

적어도 우리는 여전히 세션을 사용한다. 스프링 시큐리티와 서블릿 컨테이너가 우리의 파트에 어떠한 노력없이 어떻게 이것을 하는지 알고 있기 때문이다. 하지만, 우리가 인증토큰을 전송하기위해 쿠키를 사용해야하는 것 아닌가? 멋진 생각이지만 이것이 제대로 동작하지않는 이유가 있다. 그리고 브라우저도 이렇게 쓰는 것을 허용하지 않는다. 당신이 자바스크립트 라이브러리를 써서 브라우저의 쿠키저장소에서 값을 가지고 올 수 있지만 약간의 제약사항이 있다. 당신은 특히 서버에서 (기본적으로 세션 쿠키의 경우로 볼 수 있는) "HttpOnly"로 보내진 쿠키값에 접근할 수 없다는 것이 하나의 좋은 이유다. 또한 발신(outgoing) 요청안에 쿠키를 설정할 수 없기 때문에  우리는 (스프링세션의 기본 쿠키이름인) "세션" 쿠키를 설정할 수 없다. 따라서 커스텀 "X-Session" 헤더를 사용해야했다. 이 둘 다 사용자 스스로의 보호를 위한 제약사항들도 악의적인 스크립트가 올바른 인증없이 당신의 리소스에 접근할 수 없도록 해준다.

UI와 리소스 서버는 공통의 원본origin을 가지지 않는다. 따라서 그들은 (우리가 그들을 스프링세션을 사용하여 세션을 공유하도록 강제함에도 불구하고) 쿠키를 공유할 수 없다.

결 론 Conclusion

우리는 이 시리즈의 두번째 섹션에서 만든 어플리케이션의 기능을 복제하였다: 원격의 백엔드로부터 불러온 greeing을 가지는 홈페이지, 네비게이션바안에 로그인과 로그아웃 링크들. 리소스 서버로 부터 오는 greeing의 차이는 UI서버안에 내장된게 아니라 독립실행상태라는 점이다. 이는 구현에 중대한 복잡성을 추가해야 했지만, 좋은 점은 우리는 대부분 설정기반configuration-based (그리고 실제론 100% 선언적) 솔루션을 가지고 있다는 점이다. 모든 새 코드들을 라이브러리 (스프링 설정과 앵귤러 커스텀 디렉티브안에서 뽑아옴으로서 솔루션을 100% 선언적으로 만들수도 있다. 몇가지 인스톨후로 이 흥미로운 작업을 잠시 뒤로 미룰 것이다. 다음 섹션에서 우리는 현재의 구현의 복잡성을 줄이는 또다른 정말 훌륭한 방법을 살펴볼 것이다: API 게이트웨이 패턴 (클라이언트는 모든 요청을 한군데로 보내고 인증은 그곳에서 처리된다)

 우리는 여기에서 스프링 세션을 논리적으로 같은 어플리케이션이 아닌 두 서버간에 세션을 공유하기 위해 사용하였다. 이는 깔끔한 트릭이다. "정식"의 JEE 배포 세션에서는 가능하지않다.


반응형

+ Recent posts