1. 概要

このチュートリアルでは、OAuth2でRESTAPIを保護し、単純なAngularクライアントから使用します。

構築するアプリケーションは、次の3つの個別のモジュールで構成されます。

  • 承認サーバー
  • リソースサーバー
  • UI認証コード:認証コードフローを使用するフロントエンドアプリケーション

SpringSecurity5でOAuthスタックを使用します。SpringSecurity OAuthレガシースタックを使用する場合は、次の前の記事を参照してください: Spring REST API + OAuth2 + Angular( Spring Security OAuth Legacy Stack)

すぐに飛び込みましょう。

2. OAuth2認証サーバー(AS)

簡単に言えば、承認サーバーは承認のためにトークンを発行するアプリケーションです。

以前は、Spring Security OAuthスタックは、承認サーバーをSpringアプリケーションとしてセットアップする可能性を提供していました。 しかし、このプロジェクトは廃止されました。これは主に、OAuthが、Okta、Keycloak、ForgeRockなどの多くの定評のあるプロバイダーによるオープンスタンダードであるためです。

このうち、Keycloakを使用します。 これは、JBossによってJavaで開発された、RedHatによって管理されるオープンソースのIdentityandAccessManagementサーバーです。 OAuth2だけでなく、OpenIDConnectやSAMLなどの他の標準プロトコルもサポートします。

このチュートリアルでは、Spring Bootアプリに組み込みKeycloakサーバーをセットアップします。

3. リソースサーバー(RS)

次に、リソースサーバーについて説明します。 これは本質的にRESTAPIであり、最終的には使用できるようにしたいと考えています。

3.1. Maven構成

リソースサーバーの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. セキュリティ構成

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 プロパティは、トークンの発行者(承認サーバー)を検証するための追加のセキュリティ対策を表します。 ただし、このプロパティを追加すると、ResourceServerアプリケーションを起動する前にAuthorizationServerを実行する必要があります。

次に、エンドポイントを保護するためのAPIのセキュリティ構成を設定しましょう

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(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();
    }
}

ご覧のとおり、GETメソッドでは、readスコープを持つリクエストのみを許可します。 POST方式の場合、リクエスターは読み取りに加えて書き込み権限を持っている必要があります。 ただし、他のエンドポイントの場合、リクエストは任意のユーザーで認証される必要があります。

また、 oauth2ResourceServer()メソッドは、これが jwt()-形式のトークンを持つリソースサーバーであることを指定します。

ここで注意すべきもう1つのポイントは、メソッド cors()を使用して、リクエストでAccess-Controlヘッダーを許可することです。 Angularクライアントを扱っているため、これは特に重要です。リクエストは別のオリジンURLから送信されます。

3.4. モデルとリポジトリ

次に、モデルFoojavax.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. フロントエンド—セットアップ

次に、RESTAPIにアクセスするクライアント用の単純なフロントエンドAngular実装を見ていきます。

最初にAngularCLI を使用して、フロントエンドモジュールを生成および管理します。

まず、Angular CLIはnpmツールであるため、ノードと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>

そして最後に、はAngularCLIを使用して新しいモジュールを生成します:

ng new oauthApp

次のセクションでは、Angularアプリのロジックについて説明します。

5. Angularを使用した認証コードフロー

ここでは、OAuth2認証コードフローを使用します。

ユースケース:クライアントアプリが認証サーバーにコードを要求し、ログインページが表示されます。 ユーザーが有効な資格情報を提供して送信すると、認証サーバーからコードが提供されます。次に、フロントエンドクライアントはそれを使用してアクセストークンを取得します。

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ライブラリを使用してアクセストークンをcookieに保存します
  • getResource():IDを使用してサーバーからFooオブジェクトを取得します
  • checkCredentials():ユーザーがログインしているかどうかを確認します
  • logout():アクセストークンCookieを削除し、ユーザーをログアウトします
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('client_secret', 'newClientSecret');
    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エンコードされた形式で送信されます。 アクセストークンを取得したら、Cookieに保存します。

ここではCookieの保存が特に重要です。これは、Cookieを保存目的でのみ使用しており、認証プロセスを直接実行するためではないためです。 これは、クロスサイトリクエストフォージェリ(CSRF)攻撃と脆弱性からの保護に役立ちます。

5.3. Fooコンポーネント

最後に、 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プロジェクトにあります。