Spring REST API + OAuth2 + Angular

업데이트: Link

방금 새로운 Learn Spring Security 강좌를 발표했는데, 여기에는 Spring Security 5의 새로운 OAuth2 스택에 초점을 맞춘 전체 자료가 포함되어 있습니다.

» CHECK OUT THE COURSE

1. 개요

이 튜토리얼에서는 OAuth2로 REST API를 보호하고 간단한 Angular 클라이언트에서 이를 사용하겠습니다.

우리가 구축할 애플리케이션은 세 개의 개별 모듈로 구성됩니다:

  • Authorization Server
  • Resource Server
  • UI 인증 코드: 인증 코드 플로우를 사용하는 프런트엔드 애플리케이션

Spring Security 5에서 OAuth 스택을 사용하겠습니다. Spring Security OAuth 레거시 스택을 사용하려면 이 이전 문서를 참조하세요: Spring REST API + OAuth2 + Angular (Spring Security OAuth 레거시 스택 사용).

!!! 추가 정보:

Let’s jump right in.

2. OAuth2 인증 서버(AS)

간단히 말해, 인증 서버는 인증을 위해 토큰을 발급하는 애플리케이션입니다.

이전에는 Spring Security OAuth 스택에서 권한 서버를 Spring 애플리케이션으로 설정할 수 있는 가능성을 제공했습니다. 그러나 이 프로젝트는 더 이상 사용되지 않는데, 그 이유는 OAuth가 Okta, Keycloak, ForgeRock과 같이 잘 알려진 많은 공급자가 있는 개방형 표준이기 때문입니다.

이 중 Keycloak을 사용하겠습니다. Red Hat에서 관리하는 오픈 소스 ID 및 액세스 관리 서버로, JBoss에서 Java로 개발했습니다. OAuth2뿐만 아니라 OpenID Connect 및 SAML과 같은 다른 표준 프로토콜도 지원합니다.

이 튜토리얼에서는 스프링 부트 앱에 임베디드 키클로크 서버를 설정하겠습니다.

3. 리소스 서버(RS)

이제 리소스 서버에 대해 논의해 보겠습니다. 이것은 본질적으로 우리가 궁극적으로 사용할 수 있기를 원하는 REST API입니다.

3.1. Maven 구성

리소스 서버의 pom은 이전 인증 서버의 pom과 거의 동일하지만 키클록 부분이 없고 추가 spring-boot-starter-oauth2-resource-server 종속성이 있습니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

3.2. 보안 구성

Spring Boot를 사용하고 있으므로 부팅 속성을 사용하여 필요한 최소한의 구성을 정의할 수 있습니다.

이 작업은 application.yml 파일에서 수행합니다:

server: 
  port: 8081
  servlet: 
    context-path: /resource-server

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8083/auth/realms/baeldung
          jwk-set-uri: http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/certs

여기서는 인증에 JWT 토큰을 사용하도록 지정했습니다.

jwk-set-uri 속성은 리소스 서버가 토큰의 무결성을 확인할 수 있도록 공개 키가 포함된 URI를 가리킵니다.

issuer-uri 속성은 토큰 발급자(권한 부여 서버)의 유효성을 검사하기 위한 추가 보안 조치를 나타냅니다. 그러나 이 속성을 추가하면 리소스 서버 애플리케이션을 시작하기 전에 권한 부여 서버가 실행 중이어야 합니다.

다음으로, 엔드포인트 보안을 위해 API에 보안 구성을 설정해 보겠습니다:

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors()
            .and()
            .authorizeRequests()
            .antMatchers(HttpMethod.GET, "/user/info", "/api/foos/**")
            .hasAuthority("SCOPE_read")
            .antMatchers(HttpMethod.POST, "/api/foos")
            .hasAuthority("SCOPE_write")
            .anyRequest()
            .authenticated()
            .and()
            .oauth2ResourceServer()
            .jwt();
        return http.build();
    }
}

보시다시피 GET 메서드의 경우 read 범위의 요청만 허용합니다. POST 메서드의 경우 요청자에게 read 권한 외에 write 권한이 있어야 합니다. 그러나 다른 엔드포인트의 경우 요청은 모든 사용자에 대해 인증되어야 합니다.

또한 oauth2ResourceServer() 메서드는 _jwt()-_포맷 토큰이 있는 리소스 서버임을 지정합니다.

여기서 주목해야 할 또 다른 점은 cors() 메서드를 사용하여 요청에 Access-Control 헤더를 허용한다는 점입니다. 이것은 우리가 Angular 클라이언트를 다루고 있고 요청이 다른 원본 URL에서 올 것이기 때문에 특히 중요합니다.

3.4. 모델 및 리포지토리

다음으로, Foo 모델에 대한 _javax.persistence.Entity_를 정의해 보겠습니다:

@Entity
public class Foo {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    
    // constructor, getters and setters
}

그런 다음 _Foo_s의 저장소가 필요합니다. Spring의 _PagingAndSortingRepository_를 사용하겠습니다:

public interface IFooRepository extends PagingAndSortingRepository<Foo, Long> {
}

3.4. 서비스 및 구현

그런 다음 API에 대한 간단한 서비스를 정의하고 구현하겠습니다:

public interface IFooService {
    Optional<Foo> findById(Long id);
    Foo save(Foo foo);
    Iterable<Foo> findAll();
}

@Service
public class FooServiceImpl implements IFooService {

    private IFooRepository fooRepository;

    public FooServiceImpl(IFooRepository fooRepository) {
        this.fooRepository = fooRepository;
    }

    @Override
    public Optional<Foo> findById(Long id) {
        return fooRepository.findById(id);
    }

    @Override
    public Foo save(Foo foo) {
        return fooRepository.save(foo);
    }

    @Override
    public Iterable<Foo> findAll() {
        return fooRepository.findAll();
    }
}

3.5. 샘플 컨트롤러

이제 DTO를 통해 Foo 리소스를 노출하는 간단한 컨트롤러를 구현해 보겠습니다:

@RestController
@RequestMapping(value = "/api/foos")
public class FooController {

    private IFooService fooService;

    public FooController(IFooService fooService) {
        this.fooService = fooService;
    }

    @CrossOrigin(origins = "http://localhost:8089")    
    @GetMapping(value = "/{id}")
    public FooDto findOne(@PathVariable Long id) {
        Foo entity = fooService.findById(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        return convertToDto(entity);
    }

    @GetMapping
    public Collection<FooDto> findAll() {
        Iterable<Foo> foos = this.fooService.findAll();
        List<FooDto> fooDtos = new ArrayList<>();
        foos.forEach(p -> fooDtos.add(convertToDto(p)));
        return fooDtos;
    }

    protected FooDto convertToDto(Foo entity) {
        FooDto dto = new FooDto(entity.getId(), entity.getName());

        return dto;
    }
}

위에서 _@CrossOrigin_을 사용한 것을 확인할 수 있습니다. 이는 지정된 URL에서 실행되는 Angular 앱에서 CORS를 허용하는 데 필요한 컨트롤러 수준 구성입니다.

여기 _FooDto_가 있습니다:

public class FooDto {
    private long id;
    private String name;
}

4. Front End — Setup

이제 REST API에 액세스하는 클라이언트를 위한 간단한 프런트엔드 Angular 구현을 살펴보겠습니다.

먼저 Angular CLI를 사용하여 프런트엔드 모듈을 생성하고 관리하겠습니다.

먼저 Angular CLI는 npm 도구이므로 node and npm 을 설치합니다.

그런 다음 frontend-maven-plugin을 사용하여 Maven을 사용하여 Angular 프로젝트를 빌드해야 합니다:

<build>
    <plugins>
        <plugin>
            <groupId>com.github.eirslett</groupId>
            <artifactId>frontend-maven-plugin</artifactId>
            <version>1.3</version>
            <configuration>
                <nodeVersion>v6.10.2</nodeVersion>
                <npmVersion>3.10.10</npmVersion>
                <workingDirectory>src/main/resources</workingDirectory>
            </configuration>
            <executions>
                <execution>
                    <id>install node and npm</id>
                    <goals>
                        <goal>install-node-and-npm</goal>
                    </goals>
                </execution>
                <execution>
                    <id>npm install</id>
                    <goals>
                        <goal>npm</goal>
                    </goals>
                </execution>
                <execution>
                    <id>npm run build</id>
                    <goals>
                        <goal>npm</goal>
                    </goals>
                    <configuration>
                        <arguments>run build</arguments>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

마지막으로, 앵귤러 CLI를 사용하여 새 모듈을 생성 합니다.

ng new oauthApp

다음 섹션에서는 Angular 앱 로직에 대해 설명합니다.

5. Angular를 사용한 권한 부여 코드 흐름

여기서는 OAuth2 인증 코드 플로우를 사용하겠습니다.

사용 사례입니다: 클라이언트 앱이 인증 서버에 코드를 요청하고 로그인 페이지가 표시됩니다. 사용자가 유효한 자격 증명을 제공하고 제출하면 인증 서버가 코드를 제공합니다. 그런 다음 프런트엔드 클라이언트에서 이 코드를 사용하여 액세스 토큰을 획득합니다.

5.1. Home Component

모든 동작이 시작되는 메인 컴포넌트인 _HomeComponent_부터 시작해 보겠습니다:

@Component({
  selector: 'home-header',
  providers: [AppService],
  template: `<div class="container" >
    <button *ngIf="!isLoggedIn" class="btn btn-primary" (click)="login()" type="submit">
      Login</button>
    <div *ngIf="isLoggedIn" class="content">
      <span>Welcome !!</span>
      <a class="btn btn-default pull-right"(click)="logout()" href="#">Logout</a>
      <br/>
      <foo-details></foo-details>
    </div>
  </div>`
})
 
export class HomeComponent {
  public isLoggedIn = false;

  constructor(private _service: AppService) { }
 
  ngOnInit() {
    this.isLoggedIn = this._service.checkCredentials();    
    let i = window.location.href.indexOf('code');
    if(!this.isLoggedIn && i != -1) {
      this._service.retrieveToken(window.location.href.substring(i + 5));
    }
  }

  login() {
    window.location.href = 
      'http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/auth?
         response_type=code&scope=openid%20write%20read&client_id=' + 
         this._service.clientId + '&redirect_uri='+ this._service.redirectUri;
    }
 
  logout() {
    this._service.logout();
  }
}

처음에는 사용자가 로그인하지 않은 경우 로그인 버튼만 나타납니다. 이 버튼을 클릭하면 사용자는 사용자 이름과 비밀번호를 입력하는 AS의 인증 URL로 이동합니다. 로그인에 성공하면 인증 코드와 함께 다시 리디렉션되고 이 코드를 사용하여 액세스 토큰을 검색합니다.

5.2. App Service

이제 서버 상호 작용을 위한 로직이 포함된 _app.service.ts_에 있는 _AppService_를 살펴보겠습니다:

  • retrieveToken(): 인증 코드를 사용하여 액세스 토큰을 얻기
  • saveToken(): ng2-cookies library 에서 사용하는 access token을 cookie에 저장
  • getResource(): 를 사용하여 서버에서 ID를 사용하여 Foo 객체를 가져옵니다.
  • checkCredentials(): 를 사용하여 사용자가 로그인했는지 여부를 확인합니다.
  • logout(): 를 클릭하여 액세스 토큰 쿠키를 삭제하고 사용자를 로그아웃합니다.
export class Foo {
  constructor(public id: number, public name: string) { }
} 

@Injectable()
export class AppService {
  public clientId = 'newClient';
  public redirectUri = 'http://localhost:8089/';

  constructor(private _http: HttpClient) { }

  retrieveToken(code) {
    let params = new URLSearchParams();   
    params.append('grant_type','authorization_code');
    params.append('client_id', this.clientId);
    params.append('redirect_uri', this.redirectUri);
    params.append('code',code);

    let headers = 
      new HttpHeaders({'Content-type': 'application/x-www-form-urlencoded; charset=utf-8'});
       
      this._http.post('http://localhost:8083/auth/realms/baeldung/protocol/openid-connect/token', 
        params.toString(), { headers: headers })
        .subscribe(
          data => this.saveToken(data),
          err => alert('Invalid Credentials')); 
  }

  saveToken(token) {
    var expireDate = new Date().getTime() + (1000 * token.expires_in);
    Cookie.set("access_token", token.access_token, expireDate);
    console.log('Obtained Access token');
    window.location.href = 'http://localhost:8089';
  }

  getResource(resourceUrl) : Observable<any> {
    var headers = new HttpHeaders({
      'Content-type': 'application/x-www-form-urlencoded; charset=utf-8', 
      'Authorization': 'Bearer '+Cookie.get('access_token')});
    return this._http.get(resourceUrl, { headers: headers })
                   .catch((error:any) => Observable.throw(error.json().error || 'Server error'));
  }

  checkCredentials() {
    return Cookie.check('access_token');
  } 

  logout() {
    Cookie.delete('access_token');
    window.location.reload();
  }
}

retrieveToken 메서드에서는 클라이언트 자격 증명과 기본 인증을 사용하여 POST 방식으로 /openid-connect/token 엔드포인트로 전송하여 액세스 토큰을 가져옵니다. 매개변수는 URL 인코딩 형식으로 전송됩니다. 액세스 토큰을 얻은 후에는 쿠키에 저장합니다.

여기서 쿠키 저장은 특히 중요한데, 인증 프로세스를 직접 구동하지 않고 저장 목적으로만 쿠키를 사용하기 때문입니다. 이는 크로스 사이트 요청 위조(CSRF) 공격과 취약점으로부터 보호하는 데 도움이 됩니다.

5.3. Foo Component

마지막으로 _FooComponent_는 Foo 세부 정보를 표시합니다:

@Component({
  selector: 'foo-details',
  providers: [AppService],  
  template: `<div class="container">
    <h1 class="col-sm-12">Foo Details</h1>
    <div class="col-sm-12">
        <label class="col-sm-3">ID</label> <span></span>
    </div>
    <div class="col-sm-12">
        <label class="col-sm-3">Name</label> <span></span>
    </div>
    <div class="col-sm-12">
        <button class="btn btn-primary" (click)="getFoo()" type="submit">New Foo</button>        
    </div>
  </div>`
})

export class FooComponent {
  public foo = new Foo(1,'sample foo');
  private foosUrl = 'http://localhost:8081/resource-server/api/foos/';  

  constructor(private _service:AppService) {}

  getFoo() {
    this._service.getResource(this.foosUrl+this.foo.id)
      .subscribe(
         data => this.foo = data,
         error =>  this.foo.name = 'Error');
    }
}

5.5. App Component

루트 컴포넌트 역할을 하는 간단한 _AppComponent_입니다:

@Component({
  selector: 'app-root',
  template: `<nav class="navbar navbar-default">
    <div class="container-fluid">
      <div class="navbar-header">
        <a class="navbar-brand" href="/">Spring Security Oauth - Authorization Code</a>
      </div>
    </div>
  </nav>
  <router-outlet></router-outlet>`
})

export class AppComponent { }

그리고 모든 컴포넌트, 서비스, 경로를 감싸는 _AppModule_이 있습니다:

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    FooComponent    
  ],
  imports: [
    BrowserModule,
    HttpClientModule,
    RouterModule.forRoot([
     { path: '', component: HomeComponent, pathMatch: 'full' }], {onSameUrlNavigation: 'reload'})
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

7. Run the Front End

  1. 프런트엔드 모듈을 실행하려면 먼저 앱을 빌드해야 합니다:

     mvn clean install
    
  2. 그런 다음 Angular 앱 디렉토리로 이동해야 합니다:

     cd src/main/resources
    
  3. 마지막으로 앱을 시작합니다:

     npm start
    

서버는 기본적으로 포트 4200에서 시작되며, 모듈의 포트를 변경하려면 변경합니다:

"start": "ng serve"

예를 들어 포트 8089에서 실행되도록 하려면 _package.json;_에 다음을 추가합니다:

"start": "ng serve --port 8089"

8. 결론

이 튜토리얼에서는 OAuth2를 사용하여 애플리케이션을 인증하는 방법을 배웠습니다.

이 튜토리얼의 전체 구현은 깃허브 프로젝트에서 확인할 수 있습니다.

댓글남기기