반응형

스프링 시큐리티와 앵귤러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)


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


API 게이트웨이 The API Gateway

이 섹션에서 우리는 어떻게 스프링 시큐티리와 앵귤러JS로 단일 페이지 어플리케이션을 만드는지 계속 얘기해볼 것이다. 이제 스프링 클라우드를 사용하여 백엔드 리소스에 접근하고 인증을 제어하기 위한 API 게이트웨이를 만드는 법을 보여줄 것이다. 이 글을 시리즈의 4번째 섹션으로 당신이 어플리케이션의 기본구성을 이해하거나 처음부터 빌드해보려면 첫번째 섹션부터 읽도록 하자 또는 그냥 Github의 소스코드로 바로 가도 된다. 이전 세션에서 스프링 세션을 사용해 백엔드 리소스를 인증하기 위한 간단히 배포한 어플리케이션을 만들어보았다. 이번 섹션에서 우리는 UI서버를 백엔드 리소스 서버로 reverse proxy로 만들것이다. 지난 구현물의 문제(커스텀 토큰 인증에 의해 언급된 기술적 복잡성)를 해결하고 브라우저 클라이언트의 접근 제어를 위한 새로운 옵션을 제공할 것이다.

기억하기: 만일 당신이 이섹션의 샘플을 동작시키려면 브라우저의 쿠키와 HTTP Basic credential에 대한 캐시값을 지워주어야 한다. 크롬에선 새 incognito 창을 쓰는게 최선의 방법이다.

API 게이트웨이 만들기 Creating an API Gateway

API게이트웨이는 (이 섹션의 예제들과 같은) 브라우저에 기반의 프론트앤드 클라이언트들을 위한 단일 진입로entry이다. 클라이언트는 서버의 URL을 알아야만 하고 백엔드는 - 주요한 장점으로- 수정없이 재정의 될것이다. 중앙집중화와 제어의 또 다른 이점은 한계치 설정 인증, 감사(audit) 그리고 로깅(logging)이다. 그리고 간단한 reverse proxy를 구현하는 것은 스프링 클라우드를 쓰면 정말 간단하다.

당신이 코드를 함께 따라가려 한다면, 지난 섹션의 마지막에 했던 어플리케이션 구현은 조금 복잡하다는 것을 알고 것이다. 따라서 이 여행을 다시 시작하기에 적합한 장소는 아니다. 그러나 우리가 좀더 쉽게 시작할 수 있는 중간점이 있는데 백엔드 리소스가 아직 스프링 시큐리티에 의해 보호되지 않았던 곳이다. 이 부분의 소스코드는 Github에 별도의 프로젝트로 넣어두었으므로 우리는 여기서부터 출발할 것이다. 이 프로젝트는 UI 서버와 리소스 서버를 가지고 있고 이 둘이 서로 각각 소통하고 있다. 리소스 서버는 아직 스프링 시큐리티가 없는 상태이므로 우리는 먼저 시스템을 동작하게 한뒤 그 후 이 레이어를 추가하자.

단 한줄의 선언적 Reverse Proxy Declarative Reverse Proxy in One Line

이 프로젝트를 API 게이트웨이로 바꾸려면, UI서버를 약간 손봐줘야한다. 스프링 설정이 있는 그곳에 @EnableZuulProxy 어노테이션을 추가해줘야한다. 예를 들면 메인 어플리케이션안에 다음과 같이:

UiApplication.java

@SpringBootApplication
@RestController
@EnableZuulProxy
public class UiApplication {
  ...
}

외부 설정 파일안에 우리는 UI서버의 로컬 리소스를 외부설정("application.yml")의 원격 리소스와 맵핑 해줘야한다. 

application.yml

security:
  ...
zuul:
  routes:
    resource:
      path: /resource/**
      url: http://localhost:9000

이것의 의미는 서버의 패턴 /resource/** 의 경로를 외부서버인 localhost:9000의 같은 경로와 맵핑하라는 것이다. 간단하지만 효율적이다. (맞다. YAML로 6줄이다. 항상 이것이 필요한 것은 아니다)

이것을 동작시키려고 우리가 해야하는 전부는 클래스패스에 올바른 의존성들을 넣어주는 것이다. 이 목적을위해 우리의 메이븐 POM에 몇줄을 추가했다:

pom.xml

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-parent</artifactId>
      <version>1.0.0.BUILD-SNAPSHOT</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-zuul</artifactId>
  </dependency>
  ...
</dependencies>

"spring-cloud-starter-zuul"의 사용에 덧붙이자면, 이것은 스프링 부트가 가지고 있는 하나의 starter POM이다. 하지만 의존성을 관리하면 우리는 Zuul proxy가 필요하다. 또한 <dependencyManagement>를 사용하고 있는데 왜냐하면 우리는 모든 버젼의 의존성이 올바르게 의존될 수 있기를 원하기 때문이다.

클라이언트에서 Proxy 소비하기 Consuming the Proxy in the Client

위의 수정후에도 우리의 어플리케이션은 여전히 동작한다. 하지만 클라이언트를 수정할때까지 아직 새로운 프락시를 실제로 사용할 수 없다. 운좋게도 이것는 "home" 컨트롤러의 구현물을 다음과 같이 아주 경미하게 수정해주면 된다:

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

로컬 리소스로는:

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

이제 서버를 돌리면, 모든게 잘 작동한다. 요청은 UI (API게이트웨이)를 거쳐 리소스 서버로 프락시되어진다.

더욱 간소화 Further Simplifications

훨씬 더 나은점: 우리는 더이상 리소스 서버에 CORS 필터가 필요없다. 어쨋든 우리는 매우 신속하게 이것들을 내버렸고 우리가 무언가를 손에 의해 의존해야한다면 그것은 빨간불이다 (특히 보안과 관련해서). 운좋게도 이제 불필요하기때문에 우리는 이것을 버릴수 있고 밤에 편히 발뻗고 잘 수 있게 되었다.

리소스 서버 보안적용하기 Securing the Resource Server

아마 중간정도에 우리가 보안이 없는 리소스 서버로 시작한다고 얘기한 것을 기억해보자.

여담: 만일 당신의 네트워크 아키텍쳐가 어플리케이션 아키텍쳐를 미러(mirror)한다면, 소프트웨어에 보안의 결여는 문제조차 되지않을 수 있다. (당신은 리소스 서버를 물리적으로 접근불가능하게 만들수 있겠지만 UI 서버는 그렇지 않다). 리소스 서버를 로컬호스트에서만 접근가능하도록 만드는 간단한 실예로서, 리소스 서버의 application.properties에 다음을 추가해보자:

application.properties
server.address: 127.0.0.1

우와, 엄청 쉽네! 당신의 데이터 센터에서만 볼 수 있는 네트워크주소를 가지고 이렇게 해보라. 그리면 당신은 모든 사용자 데스크탑과 리소스서버에서 작동하는 시큐리티 솔루션을 가질 수 있다.

우리는 (수많은 이유와 같이) 소프트웨어 레벨의 시큐리티가 필요하다고 결정했다고 가정했다. 이것은 문제가 될수 없다. 왜냐하면 우리가 해야하는 모든 것은 스프링 시큐리티를 (리소스 서버 POM에) 의존성을 추가해주는것 뿐이기때문이다.

pom.xml

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

이거면 보안적용된 리소스 서버를 갖추기에 충분하다. 그러나 아직 동작하는 어플리케이션을 가질 수 없다. 두 서버 사이에 인증상태를 공유할 수 없다는 세번째 섹션에서 하지 않았던 같은 이유때문이다.

인증 상태 공유하기 Sharing Authentication State

우리는 인증(과 CSRF) 상태를 공유하기 위해 우리가 지난 섹션에서 스프링 세션을 가지고 한것과 같은 메카니즘을 사용할 수 있다. 그전에 양쪽 서버에 의존성을 추가해주자:

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>

그러나 이 시점에서의 설정은 훨씬 더 쉽다. 우리가 양쪽에 동일한 Filter선언을 추가해 두었기 때문이다. 먼저 (@EnableRedisHttpSession를 추가했던) UI서버: 

UiApplication.java

@SpringBootApplication
@RestController
@EnableZuulProxy
@EnableRedisHttpSession
public class UiApplication {

  ...

}

그 다음 리소스 서버. 다음의 3가지 작은 수정이 있다. 하나는ResourceApplication에 @EnableRedisHttpSession 추가하기:

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

또 하나는 리소스 서버의 HTTP Basic을 명시적으로 disable하기 (브라우저가 인증 다이얼로그가 팝업되는 것을 막기위해):

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

  ...

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.httpBasic().disable()
    http.authorizeRequests().anyRequest().authenticated()
  }

}

여담: 인증 다이얼로그를 막는 하나의 대안은 HTTP Basic을 유지하되 401 에러를 "기본"이외의 다른 액션을 취하게 변경하는 것이다. 당신은  HttpSecurity설정 콜백내의AuthenticationEntryPoint의 다 한 줄 구현으로 이렇게 할 수 있다.

그리고 마지막으로 application.properties에 none-stateless세션 생성정책을 명시화 하는 것이다:

application.properties

security.sessions: NEVER

레디스가 백그라운드에서 동작하고 있는 한 (이렇게 시작하려면 fig.yml을 사용하자) 시스템은 동작할 것이다. http://localhost:8080 의 UI에서 홈페이지를 불러서 로그인하면 백엔드로 부터의 메세지가 홈페이지에 랜더된 것을 볼 수 있을 것이다.

어떻게 동작하는가? How Does it Work?

보이지않는 곳에서 어떻게 동작되는가? 먼저 UI서버(API 게이트웨이)의 HTTP요청을 둘러보자:

VerbPathStatusResponse

GET

/

200

index.html

GET

/css/angular-bootstrap.css

200

Twitter bootstrap CSS

GET

/js/angular-bootstrap.js

200

Bootstrap and Angular JS

GET

/js/hello.js

200

Application logic

GET

/user

302

Redirect to login page

GET

/login

200

Whitelabel login page (ignored)

GET

/resource

302

Redirect to login page

GET

/login

200

Whitelabel login page (ignored)

GET

/login.html

200

Angular login form partial

POST

/login

302

Redirect to home page (ignored)

GET

/user

200

JSON authenticated user

GET

/resource

200

(Proxied) JSON greeting

쿠키이름이 사소하게 다른 것("JSESSIONID"대신 "SESSION")만 제외하면 두번째 섹션의 마지막에 언급된 순서와 동일하다. 이는 우리가 스프링 세션을 사용하고 있기 때문이다. 하지만 아키텍쳐는 다르다. 마지막 "/resource"로의 요청은 특별한데 이는 리소스 서버로의 프록시된 것이기 때문이다.

이제 UI서버의 "/trace" endpoint에서 보여지는 것처럼 실제 reverse proxy를 볼 수 있다. (스프링 클라우드의 의존성으로 우리가 추가했던 스프링 부트 Actuator). http://localhost:8080/trace 를 새 브라우저에서 열고 맨 밑으로 스크롤해보자 (아직 브라우저에 JSON 플러그인이 없다면 하나 받아두면 보기에도 가독성에도 좋다).  (브라우저 팝업에서) HTTTP Basic 인증을 해야할 것이지만 로그인폼에서 했던 같은 Credential이면 된다. 거의 끝무렵에 다음과 같은 요청의 묶음을 볼 수 있을 것이다:

인증이 겹치지 않게 하려면 다른 브라우저를 사용하자 (이를 테면 당신이 크롬으로 UI를 테스트해왔다면, 파이어폭스를 써라) - 동작하는 어플이 멈추진 않을테지만, 같은 브라우저에서 인증이 섞이면 trace의 가독성이 매우 떨어질 것이다. 

/trace

{
  "timestamp": 1420558194546,
  "info": {
    "method": "GET",
    "path": "/",
    "query": ""
    "remote": true,
    "proxy": "resource",
    "headers": {
      "request": {
        "accept": "application/json, text/plain, */*",
        "x-xsrf-token": "542c7005-309c-4f50-8a1d-d6c74afe8260",
        "cookie": "SESSION=c18846b5-f805-4679-9820-cd13bd83be67; XSRF-TOKEN=542c7005-309c-4f50-8a1d-d6c74afe8260",
        "x-forwarded-prefix": "/resource",
        "x-forwarded-host": "localhost:8080"
      },
      "response": {
        "Content-Type": "application/json;charset=UTF-8",
        "status": "200"
      }
    },
  }
},
{
  "timestamp": 1420558200232,
  "info": {
    "method": "GET",
    "path": "/resource/",
    "headers": {
      "request": {
        "host": "localhost:8080",
        "accept": "application/json, text/plain, */*",
        "x-xsrf-token": "542c7005-309c-4f50-8a1d-d6c74afe8260",
        "cookie": "SESSION=c18846b5-f805-4679-9820-cd13bd83be67; XSRF-TOKEN=542c7005-309c-4f50-8a1d-d6c74afe8260"
      },
      "response": {
        "Content-Type": "application/json;charset=UTF-8",
        "status": "200"
      }
    }
  }
},

두번째 엔트리는 클라이언트로부터 "/resource"의 게이트웨이까지의 요청이다. (브라우저에 추가된) 쿠키와 (두번째 섹션에서 언급된 앵귤러에 의해 추가된) CSRF헤더를 볼 수 있을 것이다. 첫번째 엔트리는 리소스 서버로의 호출이 트랙킹된다는 의미로remote: true 값을 가진다. "/" uri 경로를 가면 쿠키와 CSRF헤더가 역시 보내진 것을 볼 수 있다. 스프링 세션없이 이들 헤더는 리소스 서버에서의 의미가 없지만 우리는 이제 이들 헤더들을 인증과 CSRF토큰 데이터를 가지는 하나의 세션으로 재구성하도록 설정함으로서 요청이 받아들여진다.

결 론 Conclusion

우리는 이섹션에서 많은 것을 커버했다. 그러나 우리의 두서버의 기본 껍데기의 코드량을 최소화한 멋진 방법을 찾았다. 이 두 서버 모두 멋지게 보안이 적용되었고 사용자 경험을 절충하지 않아도 된다. 이것이 API 게이트웨이 패턴을 사용한 이유지만 사실 우리는 수박겉핥기수준으로 써본거다 ( 넷플리스는 수많은 부분에서에서 사용하고 있다). 스프링 클라우드를 읽고 게이트웨이의 수많은 기능들을 얼마나쉽게 추가할 수 있는지 찾아보자. 이 시리즈의  다음 섹션에서는 어플리케이션 아키텍쳐를 조금 확장하여 각각 별도의 서버에서 인증 책임을 뽑아낼 것이다 (싱글 사인온 패턴 the Single Sign On pattern).



반응형

반응형

스프링 시큐리티와 앵귤러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 배포 세션에서는 가능하지않다.


반응형

반응형

스프링 시큐리티와 앵귤러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 Login Page

이 섹션에서 "단일페이지 어플리케이션"을 스프링 시큐리티와 앵귤러JS를 어떻게 사용할지에 대한 얘기를 계속하려고 한다. 이 장에서 어떻게 앵귤러JS가 폼을 통해 유저를 인증하는지 그리고 UI에서 랜더하기위해 보안된 리소스를 가지고 오는지를 보여줄 것이다. 시리즈의 두번째 섹션으로, 당신은 어플리케이션의 기본 구성단위를 배워 나가도 되고, 첫번째 섹션에서부터 하나씩 만들어온 것을 빌드해봐도 되며, 아니면 그냥 Github의 소스코드.로 바로 가봐도 된다. 첫번째 섹션에서 우리는 백엔드 리소스를 보호하기 위해 HTTP Basic 인증을 사용하는 간단한 어플리케이션을 만들었다. 여기에 로그인 폼을 추가하고, 사용자가 인증을 할지 말지 정하할수 있게해주고 첫번째 섹션에서 다른 문제(CSRF 보호가 원칙적으로 결여됨)를 해결할 것이다.

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

홈페이지에 네이게이션 추가하기 Add Navigation to the Home Page

단일 페이지 어플리케이션의 핵심은 정적인 "index.html" 파일이다. 우리는 이미 정말 간단한 것 가지고 있므로 이제 이 어플리케이션에 약간의 네이게이션 기능(로그인, 로그아웃, 홈으로)을 제공해보자. 이제 ("src/main/resources/static"에서) 수정해보자:

index.html

<!doctype html>
<html>
<head>
<title>Hello AngularJS</title>
<link
	href="css/angular-bootstrap.css"
	rel="stylesheet">
<style type="text/css">
[ng\:cloak], [ng-cloak], .ng-cloak {
	display: none !important;
}
</style>
</head>

<body ng-app="hello" ng-cloak class="ng-cloak">
	<div ng-controller="navigation" class="container">
		<ul class="nav nav-pills" role="tablist">
			<li class="active"><a href="#/">home</a></li>
			<li><a href="#/login">login</a></li>
			<li ng-show="authenticated"><a href="" ng-click="logout()">logout</a></li>
		</ul>
	</div>
	<div ng-view class="container"></div>
	<script src="js/angular-bootstrap.js" type="text/javascript"></script>
	<script src="js/hello.js"></script>
</body>
</html>

사실 원래 소스와 크게 다르지않다. 아주 사소한 기능으로:

  • 네비게이션 바를 위해 <ul> 를 사용했다. 모든 링크는 홈페이지로 바로 되돌아올테지만, 일단 "라우트"설정을 했다면 앵귤러는 경로를 잘 인식할 것이다.

  • 모든 컨탠트는 "ng-view"가 있는 <div>에 부분적으로 추가될것이다.

  • "ng-cloak"은 body로 이동되었다. 우리는 앵귤러가 몇몇의 랜더링을 끝마칠때까지 전체 페이지를 숨길 것이기 때문이다. 그렇지 않으면 메뉴나 컨텐트가 페이지가 로드되는 동안 깜빡깜빡 움직일 수 있다.

  • 첫번째 섹션에서 했던대로, 프론트엔드 에셋인 "angular-bootstrap.css" 와 "angular-bootstrap.js"는 빌드시 JAR 라이브러리로부터 만들어질 것이다.


앵귤러 어플리케이션에 네비게이션 추가하기 Add Navigation to the Angular Application

이제 ("src/main/resources/public/js/hello.js" 경로에 있는) "hello" 어플리케이션을 수정하여 네비게이션 기능을 추가해보자. 일단  홈페이지의 링크를 실제로 작동하게 하는 등의 라우트를 위한 추가설정부터 하자. 

hello.js

angular.module('hello', [ 'ngRoute' ])
  .config(function($routeProvider, $httpProvider) {

	$routeProvider.when('/', {
		templateUrl : 'home.html',
		controller : 'home'
	}).when('/login', {
		templateUrl : 'login.html',
		controller : 'navigation'
	}).otherwise('/');

    $httpProvider.defaults.headers.common["X-Requested-With"] = 'XMLHttpRequest';

  })
  .controller('home', function($scope, $http) {
    $http.get('/resource/').success(function(data) {
      $scope.greeting = data;
    })
  })
  .controller('navigation', function() {});

우리는 "ngRoute" 라 불리는 앵귤러 모듈의 의존성을 추가했다. 이는 마법과 같은 $routeProvider를 설정함수에 주입할수 있게 해준다 (앵귤러는 명명규칙naming convention에 의해 의존성 주입을 하고 당신의 함수 파라미터의 이름을 인식한다). $routeProvider는 링크들을 "/" ("home" 컨트롤러)과 "/login" ("login" 컨트롤러)에 설정하는 함수의 내부에서 사용된다. "templateUrls"은 ("/"와 같은) 라우트의 루트부터의 각각의 컨트롤러에 의해 만들어지는 모델의 랜더에 사용되는 "부분적partial" 뷰들에의 상대경로relative paths이다.

커스텀 "X-Requested-With" 는 브라우저 클라이언트가 보내는 관습적인 헤더이다. 이는 앵귤러에서 기본으로 사용되었지만 1.3.0에서 떼어냈다. 스프링 시큐리티는 401 응답에 "WWW-Authenticate" 헤더를 보내지 않음으로서 응답한다. 그러므로 브라우저는 (우리는 인증하기 원하므로 우리의 앱에 바람직한) 인증창을 띄우지 않을 것이다.

"ngRoute" 모듈을 사용하기위해선, 우리는 ("src/main/wro" 경로의) 정적 에셋을 빌드하는 "wro.xml" 설정하는 라인을 추가해줘야한다:

wro.xml
<groups xmlns="http://www.isdc.ro/wro">
  <group name="angular-bootstrap">
    ...
    <js>webjar:angularjs/1.3.8/angular-route.min.js</js>
   </group>
</groups>


환영메세지 The Greeting

("src/main/resources/static"에 있는 "index.html" 바로 옆에 있는) "home.html"에 위치한 예전의 홈페이지로부터의 환영메세지 컨탠트:

home.html
<h1>Greeting</h1>
<div ng-show="authenticated">
	<p>The ID is {{greeting.id}}</p>
	<p>The content is {{greeting.content}}</p>
</div>
<div  ng-show="!authenticated">
	<p>Login to see your greeting</p>
</div>

이제 사용자가 로그인을 할지 안할지 선택할 수 있으므로 (브라우저에 의해 제어되기 전에), 우리는 보호되야하는 것과 안해도 되는 컨탠트 사이에 UI를 구별해줘야한다. (아직 없지만) authenticated변수의 참조를 추가함으로서 실현할 수 있을것이다.


로그인 폼 The Login Form

"login.html"에 있는 로그인 폼:

login.html

<div class="alert alert-danger" ng-show="error">
	There was a problem logging in. Please try again.
</div>
<form role="form" ng-submit="login()">
	<div class="form-group">
		<label for="username">Username:</label> <input type="text"
			class="form-control" id="username" name="username" ng-model="credentials.username"/>
	</div>
	<div class="form-group">
		<label for="password">Password:</label> <input type="password"
			class="form-control" id="password" name="password" ng-model="credentials.password"/>
	</div>
	<button type="submit" class="btn btn-primary">Submit</button>
</form>

위의 코드는 사용자명과 패스워드를 입력하는 두개의 입력폼이 있고 ng-submit을 통해 제출할 수 있는 버튼이 있는 아주 표준화된 로그인폼이다. 사용자는 폼태그에 아무것도 할 필요가 없다. 아마 아무것도 입력하지 않는 편에 더 낫을 것이다. 또한 앵귤러의 $scopeerror값을 가지고 있을 때만 보이는 에러메시지가 있다. 폼 제어는 앵귤러와 HTML 컨트롤러간에 데이터를 보내기 위해 ng-model을 사용한다. 이 경우 우리는 credentials 객체에 사용자명과 패스워드를 보관할 것이다. 라우트에 의해, 우리는 아직 비어있는 "navigation" 컨트롤러를 통해 로그인폼을 링크하도록 정의했다. 이제 하나씩 채워보도록 하자.


인증 프로세스 The Authentication Process

우리가 추가한 로그인폼을 작동시키려면 약간의 기능을 더 추가해줘야 한다. 클라이언트 쪽에선 "navigation" 컨트롤러를 구현할 것이다. 서버쪽에선 스프링 시큐리티 설정을 할 것이다.


로그인 폼 제출하기 Submitting the Login Form

폼을 제출하려면, 우리가 이미 ng-submit를 통해 폼안에 이미 참조해둔 login() 함수를 정의해줘야한다. credentials객체는 ng-model을 통해 참조될 것이다. 이제 "hello.js"안에 위치한 "navigation" 컨트롤러에 살을 붙여보자 (라우트 설정과 "home" 컨트롤러는 생각한다):

hello.js
angular.module('hello', [ 'ngRoute' ]) // ... omitted code
.controller('navigation',

  function($rootScope, $scope, $http, $location) {

  var authenticate = function(credentials, callback) {

    var headers = credentials ? {authorization : "Basic "
        + btoa(credentials.username + ":" + credentials.password)
    } : {};

    $http.get('user', {headers : headers}).success(function(data) {
      if (data.name) {
        $rootScope.authenticated = true;
      } else {
        $rootScope.authenticated = false;
      }
      callback && callback();
    }).error(function() {
      $rootScope.authenticated = false;
      callback && callback();
    });

  }

  authenticate();
  $scope.credentials = {};
  $scope.login = function() {
      authenticate($scope.credentials, function() {
        if ($rootScope.authenticated) {
          $location.path("/");
          $scope.error = false;
        } else {
          $location.path("/login");
          $scope.error = true;
        }
      });
  };
});

메뉴바에 있는 <div>는 visible과 ng-controller="navigation"로 코드되어 있으므로, 페이지가 로드될때 "navigation" 컨트롤러내의 모든 소스코드가 실행될 것이다. 이 컨트롤러는 credentials객체를 초기화하는 것 뿐만 아니라 두개의 함수를 정의하고 있다. login()함수는 폼에서 필요하고,  authenticate()는 로컬 헬퍼local helper 함수로서 백엔드에서 "user" 리소스를 불러오는데 쓰인다. authenticate()함수는 (이를테면, 만일 사용자가 세션을 설정하는 중간에 브라우저를 새로고치는 등으로) 컨트롤러가 로드될때 유저가 이미 인증을 받았는지를 확인할때 호출된다. 서버에서 이미 인증을 받았을 때 원격호출을 하기위해 authenticate()가 필요하다 그리고 우리는 이 인증을 유지하는 것을 브라우저에 맡기길 원치않는다.

authenticate()함수는 authenticated이라 부르는 어플리케이션 차원의 플래그로서 설정되는데 이는 우리가 "home.html"에서 페이지의 어느 부분을 렌더할지 제어하기 위해 이미 사용했었다. "navigation"과 "home" 컨트롤러간에 authenticated 플래그를 공유하는 목적으로 $rootScope를 사용하는 데 이는 편안하고 쉽게 따라할 수 있기 때문이다. 앵귤러 전문가라면 아마 공유되는 사용자 정의 서비스를 통해 데이터를 공유하는 편을 선호할 것이다 (하지만 이것도 결국 따지고보면 같은 메카니즘이다)

authenticate()는 (당신의 어플리케이션의 배포루트에 상대적인) 상대적 리소스 "/user" 에 GET 호출을 한다.  login() 함수가 호출될때 Base64 인코딩된 credential을 헤더에 추가한다. 서버에서 이걸로 인증을하고 돌아오는 쿠키를 되돌려준다. login() 함수 역시 인증의 결과를 받아올때 로그인폼에 에러메세지를 표시하는데 쓰이는 로컬 $scope.error 플래그를 설정한다.


현재 인증된 사용자 The Currently Authenticated User

authenticate() 함수를 서비스하려면 우리는 백엔드에 새로운 종단endpoint을 추가해야한다:

UiApplication.java
@SpringBootApplication
@RestController
public class UiApplication {

  @RequestMapping("/user")
  public Principal user(Principal user) {
    return user;
  }

  ...

}

스프링 시큐리티 어플리케이션에서 이렇게 설정하는 것은 유용한 전략이다. "/user" 리소스에 접근할 권한이 있다면, 현재 인증된 유저(Authentication)를 되돌려줄것이다. 그렇지 않다면 스프링 시큐리티는 요청을 가로챈후 AuthenticationEntryPoint를 거쳐 401 응답을 보낸다.

서버에서 로그인 요청을 처리하기 Handling the Login Request on the Server

스프링 시큐리티로 로그인 요청을 쉽게 처리할 수 있다. 그냥 우리의 메인 어플리케이션 클래스 (inner클래스로서)에 약간의 설정만 추가해주면 된다:

UiApplication.java

@SpringBootApplication
@RestController
public class UiApplication {

  ...

  @Configuration
  @Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
  protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
      http
        .httpBasic()
      .and()
        .authorizeRequests()
          .antMatchers("/index.html", "/home.html", "/login.html", "/").permitAll()
          .anyRequest().authenticated();
    }
  }

}

위의 코드는 단지 정적인 리소스(HTML)에의 익명의 접근을 허용하도록 커스터마이즈한 (CSS와 JS리소스는 이미 기본값으로 접근가능하다) 스프링 시큐리티를 가지고 표준화된 스프링 어플리케이션이다, HTML리소스는 스프링 시큐리티가 무시하도록 설정함으로서 익명의 사용자도 이용가능해야한다.


로그아웃 Logout

어플리케이션이 기능적으로 거의 완성되었다 마지막으로 우리는 홈페이지에서 밑그림을 그려둔 로그아웃 기능을 구현하려고한다. 다음과 같이 만들어둔 네이게이션 바를 상기해보자:

index.html

<div ng-controller="navigation" class="container">
  <ul class="nav nav-pills" role="tablist">
    <li class="active"><a href="#/">home</a></li>
    <li><a href="#/login">login</a></li>
    <li ng-show="authenticated"><a href="" ng-click="logout()">logout</a></li>
  </ul>
</div>

사용자가 인증을 받은 상태면, 로그아웃 링크를 볼 수 있다. 이는 "navigation" 컨트롤러안의 logout()함수와 묶여있다. 이 함수의 구현은 상대적으로 간단하다:

hello.js
angular.module('hello', [ 'ngRoute' ]).
// ...
.controller('navigation', function(...) {

...

$scope.logout = function() {
  $http.post('logout', {}).success(function() {
    $rootScope.authenticated = false;
    $location.path("/");
  }).error(function(data) {
    $rootScope.authenticated = false;
  });
}

...

});

이제 우리가 서버쪽을 구현해야할 "/logout"으로 HTTP POST를 보낸다.  스프링 시큐리티에 의해 이미 추가되었으므로 매우 직관적이다.(한마디로, 우리는 이 간단한 시나리오에 아무것도 할 필요없다.). 하지만 로그아웃 이후의 비지니스 로직을 돌리려고 하는 등으로 로그아웃의 동작을 좀 더 제어해야한다면 WebSecurityAdapter에 있는 HttpSecurity 콜백을 사용할 수 있다. 


CSRF 보안 CSRF Protection

이제 어플리케이션이 거의 준비되었다. 사실 실행해보면 우리가 로그아웃 링크를 제외한 지금까지 실제로 작업한 모든 것을 확인해볼 수 있다. 한번 테스트하고 브라우저의 응답을 둘러보면 왜 그런지 알 수 있다:

POST /logout HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded

username=user&password=password

HTTP/1.1 403 Forbidden
Set-Cookie: JSESSIONID=3941352C51ABB941781E1DF312DA474E; Path=/; HttpOnly
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
...

{"timestamp":1420467113764,"status":403,"error":"Forbidden","message":"Expected CSRF token not found. Has your session expired?","path":"/login"}

보다시피 스프링 시큐리티의 내장 CSRF protection가 우리 스스로의 요청도 막아버리고 있으므로 잘 동작한다고 할 수 있다. CSRF protection이 원하는건 헤더에 "X-CSRF"라는 토큰이 들어있는 것이다. CSRF 토큰의 값은 홈페이지가 불러지는 첫번째 요청의 HttpRequest속성값을 가지고 서버에서 이용한다. 클라이언트가 이 값을 받으려면, 서버에서 동적 HTML 를 사용해서 랜더하거나 커스텀 종단endpoint를 통해 노출시키거나 또는 우리가 쿠키로서 보내거나 해야한다. 앵귤러가 쿠키에 기반한 ("XSRF"라 부르는) 내장된 CSRF지원을 가지고 있기 때문에 마지막 옵션이 최고의 선택이 될것이다.

서버에서는 쿠키를 보내는 커스텀 필터가 필요하다. 앵귤러는 쿠키이름을 "XSRF-TOKEN"라고 받길 원하므로 스프링 시큐리티는 요청 속성값으로 제공해줘야 한다. 요청속성값에 쿠키를 다음과 같이 전송하면 된다:

CsrfHeaderFilter.java

public class CsrfHeaderFilter extends OncePerRequestFilter {
  @Override
  protected void doFilterInternal(HttpServletRequest request,
      HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {
    CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class
        .getName());
    if (csrf != null) {
      Cookie cookie = WebUtils.getCookie(request, "XSRF-TOKEN");
      String token = csrf.getToken();
      if (cookie==null || token!=null && !token.equals(cookie.getValue())) {
        cookie = new Cookie("XSRF-TOKEN", token);
        cookie.setPath("/");
        response.addCookie(cookie);
      }
    }
    filterChain.doFilter(request, response);
  }
}

이것을 완전하게 제네릭하게 만들어 완성하려면,  ("/"를 하드코드하는 대신) 어플리케이션의 컨텍스트 경로에 쿠키 경로를 신중하게 설정해야한다. 하지만 우리가 만드는 이 어플리케이션수준에선 충분하다.

어플리케이션의 어딘가에 이 필터를 설치해야하는데 이 요청의 속성값을 사용하려면 스프링 시큐리티의 CsrfFilter이후에 불려져야한다. 이들 리소스를 보호하기 위해 스프링 시큐리티가 이미 사용중이므로 스프링 시큐리티의 Spring Security filter chain이 최적의 장소이다. 예를 들면, 위의 SecurityConfiguration를 다음과 같이 확장해보자:

SecurityConfiguration.java

@Configuration
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
      .httpBasic().and()
      .authorizeRequests()
        .antMatchers("/index.html", "/home.html", "/login.html", "/").permitAll().anyRequest()
        .authenticated().and()
      .addFilterAfter(new CsrfHeaderFilter(), CsrfFilter.class);
  }
}

서버단에서 해줘야하는 또 다른 작업은 스프링 시큐리티에 앵귤러가 받기를 원하는 포맷 (앵귤러는 스프링의 기본값인 "X-CSRF-TOKEN" 대신 "X-XRSF-TOKEN" 이름을 헤더에 쓴다)의 CSRF토큰을 알려줘야한다.

SecurityConfiguration.java

@Override
protected void configure(HttpSecurity http) throws Exception {
  http
    .httpBasic().and()
    ...
    .csrf().csrfTokenRepository(csrfTokenRepository());
}

private CsrfTokenRepository csrfTokenRepository() {
  HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
  repository.setHeaderName("X-XSRF-TOKEN");
  return repository;
}

위의 수정으로 이제 클라이언트 쪽의 아무런 수정없이 로그인폼이 잘 작동한다.


어떻게 작동하나? How Does it Work?

개발자 툴 (크롬에선 기본값으로 F12를 누르면 된다. 파이이폭스는 플러그인을 설치해야한다)을 통해, 브라우저와 백엔드간의 연동을 브라우저에서 확인할 수 있다. 아래 정리해두었다:

VerbPathStatusResponse

GET

/

200

index.html

GET

/css/angular-bootstrap.css

200

트위터 부트스트랩 CSS

GET

/js/angular-bootstrap.js

200

부트스트랩과 앵귤러JS

GET

/js/hello.js

200

어플리케이션 로직

GET

/user

401

Unauthorized

GET

/home.html

200

홈페이지

GET

/resource

401

Unauthorized

GET

/login.html

200

앵귤러 로그인 폼 일부

GET

/user

401

Unauthorized

GET

/user

200

인증credentials보내고 JSON받기

GET

/resource

200

JSON greeting

위에 "ignore"로 표시된 응답은 앵귤러가 XHR호출로 받은 HTML응답이다. 우리는 HTML로 떨어진 데이터를 처리하지 않으므로 "/user"리소스의 경우 인증된 유저만 볼 수 있다. 하지만 첫번째 호출에는 없기때문에 응답이 받아진다.

요청을 더 자세히 들여다보면 그들이 모두 쿠키값을 가지고 있는 것을 확인할 수 있을 것이다. (크롬의 incognito와 같은) 클린 브라우저로 시작을 하면, 맨 처음 요청에는 서버에서 보내진 쿠키가 없지만 서버는 JSESSIONID" (일반적으로HttpSession)와 X-XSRF-TOKEN" (우리가 위에서 설정한 CRSF 쿠키)를 위해 "Set-Cookie"를 돌려준다. 이 값들은 매우 중요한데 어플리케이션은 이들없이 동작하지않는다. 이 값들은 매우 기본적인 보안기능(인증과 CSRF보호)을 제공해주고 있다. 쿠키의 값은 유저가 (POST 호출로) 인증받으면 바뀐다. 이는 (session fixation attacks을 방지하는) 또 하나의 중요한 보안기능이다.

CSRF보호를 위해 서버에서 받은 쿠키에 의존하는 것은 적절하지 않다. 브라우저는 당신이 어플리케이션에서 불러진 페이지에 있지않을 때도 자동적으로 이것을 보내기 때문이다.(XSS라고 알려진 Cross Site Scripting Attack에 의해). 헤더는 자동적으로 보내지지 않으므로 원본은 지켜진다. 사용자는 어플리케이션에서 CSRF토큰이 클라이언트에 쿠키값으로 보내지는 것을 확인할 수 있다. 따라서 우리는 브라우저에 의해 자동적으로 되돌려질 것이다. 그러나 이것은 보호되고 있는 헤더이다.

도와줘,, 내 어플리케이션을 어떻게 스케일하지?

"그런데 잠시만…싱글페이지 어플리케이션에서 세션 상태을 사용하는건 안좋은거 아닌가요?" 라고 물어본다면, 대답은 "보통은 그렇다" 이다. CSRF보안과 인증에 세션상태을 사용하는 것은 확실히 매우 좋다. 이 상태는 어딘가에 저장되어야한다 그리고 사용자가 세션을 떼어내면 어딘가에 넣어두고 그것을 사용자 스스로 관리해줘야할 것이다. 서버와 클라이언트 양쪽에서 이것은 더 많은 코드와 유지보스를 필요로 하며 이것은 완전히 불필요한 일을 하는 것이다.

"하지만,, 하지만… 그럼 이제 어떻게하면 내 어플리케이션의 규모를 크게 스케일할 수 있죠?". 이 "진짜" 질문은 당신이 위에서 했던 것이지만, 세션은 나쁘다 -> 나는 상태없는stateless 상태로 가야한다고 생각한다로 수렴되는 경향이 있다. 놀랄 것없다. 여기서 받아들여지는 중요한 점은 보안은 stateful이라는 점이다. 당신은 안전한 stateless한 어플리케이션을 가질 수 없다. 그럼 어디에 state를 저장할 것인가? 이게 여기서 얘기할 수 있는 전부다. Rob Winch 는 Spring Exchange 2014 에서 state가 왜 필요한지 (또 이는 어디에서나 쓰인다. - TCP와 SSL은 statfule이고 따라서 당신이 알았던 몰랐던 당신의 시스템도 stateful 이다) 설명한 매우 유용하고 통찰력있는 (이 주제에 대해 더 깊이 파고 들고 싶어하다고 느낄만한) 얘기를 했었다

좋은 소식은 당신은 선택할 수 있다는 것이다. 가장 쉬운 선택은 세션데이터를 메모리(in-memory)에 저장하고 당신의 로드밸러서에 세션를 전적으로 맡겨서 같은 JVM에서 같은 세션을 돌려주도록 요청을 라우트하는 것이다.  이 방법은 당신이 맨땅에서 시작하기에 충분히 좋을 뿐만아니라 실제 수많은 사용예에서 잘 작동할 것이다. 또 다른 선택은 어플리케이션 인스턴스간에 세션 데이터를 공유하는 것이다. 사용자가 엄격히 보안데이터만을 저장하고 이 데이터가 작으며 드물게 바뀌는 것이라면 (유저가 로그인, 로그아웃하거나 사용자 세션이 타임아웃됬을때만) 그래서 주요 기반(infrastructure)의 문제가 없다면, 스프링 세션으로 아주 쉽게 해결할 수 있다. 우리는 이 시리즈의 다음 섹션에서 스프링 세션을 사용할 것이므로 여기서 어떻게 설정하는 지 등에 대해 자세하게 들어갈 필요는 없다. 하지만 말그대로 코드 몇줄과 레디스 서버면 된다.

 또다른 손쉬운 방법은 세션 상태를 공유하도록 설정하고 클라우드 파운더리의 Pivotal Web Services에 WAR파일로 어플리케이션을 배포한뒤 레디스 서비스에 바인드하는 것이다.

하지만 내 커스텀 토큰 구현은 어떻게 되지? (이건 Stateless인데?)

이게 이 마지막 섹션에의 당신의 질문이라면, 아마도 당신은 처음부터 알아듣지 못한 것같으니 위의 글을 다시 읽어보자. 당신이 어딘가에 토큰을 저장한다면 그것은 stateless가 아닐 것이다. 그러나 당신이 (JWT 인코드된 토큰을 사용함으로서) 따로 저장하지않는다면, 어떻게 CSRF보호를 할 것인가? 이것은 중요한 문제다. 여기 (Rob Winch가 결론지은) 경험의 법칙이 있다: 당신의 어플리케이션이나 API를 브라우저로 접근한다면, 당신은 CSRF 보호가 필요하다. 이것은 세션이 없다고 못하는 것은 아니다. 그냥 당신 스스로 코드를 만들면된다. 요점은 이미 HttpSession 기반으로 아주 완벽히 동작하게 구현되어있는게 있다는 것이다. 당신이 CSRF가 필요없고 완벽히 (세션을 사용하지않는) "stateless" 토큰 구현체를 쓰도록 결심했다 하더라도, 당신은 여전히 클라이언트에 이를 (당신이 브라우저에 위임하도록 한 것) 사용하도록 그리고 서버의 스스로 내장된 기능(기본값을 끄지않으면 브라우저는 항상 쿠키를 보내고 서버는 항상 세션을 가진다)을 가지도록 추가의 코드를 만들어야만 한다. 그 코드는 비지니스 로직도 아니고 그걸로 돈을 벌수 있는 것도 아니다. 그럴만한 가치가 있다하더라도 추가적인 비용이 들어가는 그냥 간접비용일 뿐이다. 

결 론 Conclusion

어플리케이션은 이제 사용자가 기대하는 "실제" 환경에서 돌아가는 "실전" 어플리케이션에 더 가까워졌다. 이 아키텍쳐을 기반으로 더 풍부한 기능을 더할 수 있는 템플릿으로서 사용할 수 있을 것이다. (정적 컨텐트와 JSON응답을 가지는 단일 서버). 우리는 보안데이터를 저장하기위해 클라이언트에 우리가 보낸 쿠키를 사용하도록 하여 HttpSession를 사용하고 있다. 우리는 이제 아주 편안하다 왜냐하면 우리 스스로의 비지니스 도메인에 집중하면 되기 때문이다. 다음 섹션에서 우리는 아키텍쳐를 확장하여 인증과 UI서버를 분리할 것이다. 또 JSON을 위해 독립 리소스 서버를 갖출 것이다. 이 다중 리소스 서버는 손쉽게 만들 수 있다. 또한 스프링 세션을 소개하여 어떻게 인증데이터를 공유하는데 활용하는지 보여줄 것이다.


반응형

+ Recent posts