1. 개요

이 사용방법(예제)에서는 OAuth2로 REST API를 보호하고 간단한 Angular 클라이언트에서 사용합니다.

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

  • 인증 서버
  • 리소스 서버
  • UI 인증 코드: 인증 코드 흐름을 사용하는 프런트 엔드 애플리케이션

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

바로 뛰어들자.

2. OAuth2 인증 서버(AS)

간단히 말해 권한 부여 서버는 권한 부여를 위해 토큰을 발행하는 애플리케이션입니다.

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

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

이 예제에서는 Spring Boot 앱에 포함된 Keycloak 서버를 설정합니다 .

3. 리소스 서버(RS)

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

3.1. 메이븐 구성

리소스 서버의 pom은 이전 Authorization Server pom과 거의 동일하며 Keycloak 부분이 없고 추가 spring-boot-starter-oauth2-resource-server 의존성 이 있습니다 .

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

3.2. Security 구성

Spring Boot를 사용하고 있으므로 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 특성 은 토큰 발행자(인증 서버)를 검증하기 위한 추가 Security 수단을 나타냅니다. 그러나 이 속성을 추가하면 리소스 서버 응용 프로그램을 시작하기 전에 권한 부여 서버가 실행 중이어야 합니다.

다음으로 엔드포인트를 보호하기 위해 API에 대한 Security 구성을 설정해 보겠습니다 .

@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 메서드의 경우 읽기 범위가 있는 요청만 허용합니다. POST 방식의 경우 요청자는 읽기 외에 쓰기 권한 이 있어야 합니다 . 그러나 다른 Endpoints의 경우 요청은 모든 사용자에 대해 인증되어야 합니다.

또한 oauth2ResourceServer() 메서드 는 이것이 jwt() 형식의 토큰 이 있는 리소스 서버임을 지정합니다 .

여기서 주목해야 할 또 다른 점 은 요청에 액세스 제어 헤더를 허용하기 위해 cors() 메서드를 사용하는 것입니다. 이것은 우리가 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 의 저장소가 필요합니다 . 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. 프런트 엔드 - 설정

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

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

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

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

<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>

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

ng new oauthApp

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

5. Angular를 사용한 인증 코드 흐름

여기서는 OAuth2 인증 코드 흐름을 사용하겠습니다.

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

5.1. 홈 구성 요소

모든 작업이 시작되는 주요 구성 요소인 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. 앱 서비스

이제 서버 상호 작용을 위한 논리가 포함된 AppService ( app.service.ts 에 있음)를 살펴보겠습니다.

  • retrieveToken() : 인증 코드를 사용하여 액세스 토큰을 얻습니다.
  • saveToken() : ng2-cookies 라이브러리를 사용하여 쿠키에 액세스 토큰을 저장합니다.
  • 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 메서드 에서 클라이언트 자격 증명과 기본 인증을 사용 하여 /openid-connect/token 엔드포인트에 POST 를 보내 액세스 토큰을 얻습니다. 매개변수는 URL 인코딩 형식으로 전송됩니다. 액세스 토큰을 얻은 후 쿠키에 저장합니다.

여기서 쿠키 저장은 저장 목적으로만 쿠키를 사용하고 인증 프로세스를 직접 구동하지 않기 때문에 여기에서 특히 중요합니다. 이를 통해 CSRF(Cross-Site Request Forgery) 공격 및 취약성으로부터 보호할 수 있습니다.

5.3. 푸 컴포넌트

마지막으로 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>{{foo.id}}</span>
    </div>
    <div class="col-sm-12">
        <label class="col-sm-3">Name</label> <span>{{foo.name}}</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. 앱 구성 요소

루트 구성 요소 역할을 하는 간단한 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. 프런트 엔드 실행

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

mvn clean install

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

cd src/main/resources

3. 마지막으로 앱을 시작합니다.

npm start

서버는 기본적으로 포트 4200에서 시작됩니다. 모듈의 포트를 변경하려면 다음을 변경하십시오.

"start": "ng serve"

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

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

8. 결론

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

이 사용방법(예제)의 전체 구현은 GitHub 프로젝트 에서 찾을 수 있습니다 .

Security footer banner