Spring REST API + OAuth2 + Angular – Spring REST API + OAuth2 + Angular

最后修改: 2015年 11月 17日

中文/混合/英文(键盘快捷键:t)

1. Overview

1.概述

In this tutorial, we’ll secure a REST API with OAuth2 and consume it from a simple Angular client.

在本教程中,我们将用OAuth2确保REST API的安全,并从一个简单的Angular客户端消费它。

The application we’re going to build out will consist of three separate modules:

我们要建立的应用程序将由三个独立的模块组成。

  • Authorization Server
  • Resource Server
  • UI authorization code: a front-end application using the Authorization Code Flow

We’ll use the OAuth stack in Spring Security 5. If you want to use the Spring Security OAuth legacy stack, have a look at this previous article: Spring REST API + OAuth2 + Angular (Using the Spring Security OAuth Legacy Stack).

我们将在Spring Security 5中使用OAuth堆栈。如果你想使用Spring Security OAuth遗留堆栈,可以看看之前的这篇文章。Spring REST API + OAuth2 + Angular(使用Spring Security OAuth Legacy Stack)

Let’s jump right in.

让我们直接跳进去。

2. The OAuth2 Authorization Server (AS)

2.OAuth2授权服务器(AS)

Simply put, an Authorization Server is an application that issues tokens for authorization.

简单地说,授权服务器是一个为授权发放令牌的应用程序。

Previously, the Spring Security OAuth stack offered the possibility of setting up an Authorization Server as a Spring Application. But the project has been deprecated, mainly because OAuth is an open standard with many well-established providers such as Okta, Keycloak, and ForgeRock, to name a few.

以前,Spring Security OAuth栈提供了将授权服务器设置为Spring应用程序的可能性。但这个项目已经被废弃了,主要是因为OAuth是一个开放的标准,有很多成熟的供应商,如Okta、Keycloak和ForgeRock等等。

Of these, we’ll be using Keycloak. It’s an open-source Identity and Access Management server administered by Red Hat, developed in Java, by JBoss. It supports not only OAuth2 but also other standard protocols such as OpenID Connect and SAML.

其中,我们将使用Keycloak>。这是一个开源的身份和访问管理服务器,由Red Hat管理,用Java开发,由JBoss负责。它不仅支持OAuth2,而且还支持其他标准协议,如OpenID Connect和SAML。

For this tutorial, we’ll be setting up an embedded Keycloak server in a Spring Boot app.

在本教程中,我们将在Spring Boot应用程序中设置一个嵌入式Keycloak服务器

3. The Resource Server (RS)

3.资源服务器(RS)

Now let’s discuss the Resource Server; this is essentially the REST API, which we ultimately want to be able to consume.

现在让我们来讨论一下资源服务器;这基本上是REST API,我们最终希望能够消费它。

3.1. Maven Configuration

3.1.Maven配置

Our Resource Server’s pom is much the same as the previous Authorization Server pom, sans the Keycloak part and with an additional spring-boot-starter-oauth2-resource-server dependency:

我们的资源服务器的pom与之前的授权服务器的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 Configuration

3.2.安全配置

Since we’re using Spring Boot, we can define the minimal required configuration using Boot properties.

由于我们使用的是Spring Boot,我们可以使用Boot属性定义所需的最小配置。

We’ll do this in an application.yml file:

我们将在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

Here, we specified that we’ll use JWT tokens for authorization.

在这里,我们指定将使用JWT令牌进行授权。

The jwk-set-uri property points to the URI containing the public key so that our Resource Server can verify the tokens’ integrity. 

jwk-set-uri属性指向包含公钥的URI,以便我们的资源服务器能够验证令牌的完整性。

The issuer-uri property represents an additional security measure to validate the issuer of the tokens (which is the Authorization Server). However, adding this property also mandates that the Authorization Server should be running before we can start the Resource Server application.

issuer-uri 属性代表一种额外的安全措施,以验证令牌的发行者(即授权服务器)。然而,添加这个属性也规定,在我们启动资源服务器应用程序之前,授权服务器应该正在运行。

Next, let’s set up a security configuration for the API to secure endpoints:

接下来,让我们为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();
    }
}

As we can see, for our GET methods, we only allow requests that have read scope. For the POST method, the requester needs to have a write authority in addition to read. However, for any other endpoint, the request should just be authenticated with any user.

我们可以看到,对于我们的GET方法,我们只允许有范围的请求。对于POST方法,除了之外,请求者还需要有权限。然而,对于任何其他的端点,请求应该只是通过任何用户的认证。

Also, the oauth2ResourceServer() method specifies that this is a resource server, with jwt()-formatted tokens.

另外,oauth2ResourceServer()方法指定这是一个资源服务器,使用jwt()-格式化的令牌。

Another point to note here is the use of method cors() to allow Access-Control headers on the requests. This is especially important since we are dealing with an Angular client, and our requests are going to come from another origin URL.

这里需要注意的另一点是使用方法cors()来允许请求中的访问控制头。这一点特别重要,因为我们正在处理一个Angular客户端,而我们的请求将来自另一个原点URL。

3.4. The Model and Repository

3.4.模型和存储库

Next, let’s define a javax.persistence.Entity for our model, Foo:

接下来,让我们为我们的模型javax.persistence.Entity定义一个Foo

@Entity
public class Foo {

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

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

Then we need a repository of Foos. We’ll use Spring’s PagingAndSortingRepository:

然后我们需要一个Foos的存储库。我们将使用Spring的PagingAndSortingRepository

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

3.4. The Service and Implementation

3.4.服务和实施

After that, we’ll define and implement a simple service for our API:

之后,我们将为我们的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. A Sample Controller

3.5.一个控制器样本

Now let’s implement a simple controller exposing our Foo resource via a DTO:

现在让我们实现一个简单的控制器,通过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;
    }
}

Notice the use of @CrossOrigin above; this is the controller-level config we need to allow CORS from our Angular App running at the specified URL.

注意到上面使用的@CrossOrigin;这是我们需要的控制器级配置,以允许从我们的Angular App运行在指定的URL上的CORS。

Here’s our FooDto:

这是我们的FooDto

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

4. Front End — Setup

4.前端–设置

We’re now going to look at a simple front-end Angular implementation for the client, which will access our REST API.

我们现在要看一下客户端的一个简单的前端Angular实现,它将访问我们的REST API。

We’ll first use Angular CLI to generate and manage our front-end modules.

我们将首先使用Angular CLI来生成和管理我们的前端模块。

First, we install node and npm, as Angular CLI is an npm tool.

首先,我们安装node和npm,因为Angular CLI是一个npm工具。

Then we need to use the frontend-maven-plugin to build our Angular project using Maven:

然后我们需要使用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>

And finally, generate a new Module using Angular CLI:

最后,使用Angular CLI生成一个新模块:

ng new oauthApp

In the following section, we will discuss the Angular app logic.

在下一节中,我们将讨论Angular应用程序的逻辑。

5. Authorization Code Flow Using Angular

5.使用Angular的授权代码流

We’re going to use the OAuth2 Authorization Code flow here.

我们将在这里使用OAuth2授权代码流程。

Our use case: The client app requests a code from the Authorization Server and is presented with a login page. Once a user provides their valid credentials and submits, the Authorization Server gives us the code. Then the front-end client uses it to acquire an access token.

我们的用例。客户端应用程序从授权服务器请求一个代码,并呈现一个登录页面。一旦用户提供了他们的有效凭证并提交,授权服务器就会给我们代码。然后前端客户端使用它来获取一个访问令牌。

5.1. Home Component

5.1.主页组件

Lets’ begin with our main component, the HomeComponent, where all the action starts:

让我们从我们的主要组件开始,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();
  }
}

In the beginning, when the user is not logged in, only the login button appears. Upon clicking this button, the user is navigated to the AS’s authorization URL where they key in username and password. After a successful login, the user is redirected back with the authorization code, and then we retrieve the access token using this code.

在开始时,当用户没有登录时,只出现登录按钮。点击这个按钮后,用户会被导航到AS的授权URL,在那里他们输入用户名和密码。登录成功后,用户会被重定向到授权代码,然后我们用这个代码检索访问令牌。

5.2. App Service

5.2.应用服务

Now let’s look at AppService — located at app.service.ts — which contains the logic for server interactions:

现在让我们看看AppService–位于app.service.ts–它包含服务器交互的逻辑。

  • retrieveToken(): to obtain access token using authorization code
  • saveToken(): to save our access token in a cookie using ng2-cookies library
  • getResource(): to get a Foo object from server using its ID
  • checkCredentials(): to check if user is logged in or not
  • logout(): to delete access token cookie and log the user out
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();
  }
}

In the retrieveToken method, we use our client credentials and Basic Auth to send a POST to the /openid-connect/token endpoint to get the access token. The parameters are being sent in a URL-encoded format. After we obtain the access token, we store it in a cookie.

retrieveToken方法中,我们使用客户证书和Basic Auth向/openid-connect/token端点发送POST以获得访问令牌。参数是以URL编码格式发送的。在我们获得访问令牌后,我们将其存储在一个 cookie 中。

The cookie storage is especially important here because we’re only using the cookie for storage purposes and not to drive the authentication process directly. This helps protect against Cross-Site Request Forgery (CSRF) attacks and vulnerabilities.

cookie存储在这里特别重要,因为我们只是将cookie用于存储目的,而不是直接驱动认证过程。这有助于防止跨站请求伪造(CSRF)攻击和漏洞。

5.3. Foo Component

5.3.Foo组件

Finally, our FooComponent to display our Foo details:

最后,我们的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. App Component

5.5.应用程序组件

Our simple AppComponent to act as the root 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 { }

And the AppModule where we wrap all our components, services and routes:

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

7.运行前端

1. To run any of our front-end modules, we need to build the app first:

1.要运行我们的任何前端模块,我们需要先建立应用程序。

mvn clean install

2. Then we need to navigate to our Angular app directory:

2.然后我们需要导航到我们的Angular应用目录。

cd src/main/resources

3. Finally, we will start our app:

3.最后,我们将启动我们的应用程序。

npm start

The server will start by default on port 4200; to change the port of any module, change:

服务器将默认在端口4200上启动;要改变任何模块的端口,请改变。

"start": "ng serve"

in package.json; for example, to make it run on port 8089, add:

package.json中;例如,为了使它在8089端口运行,添加。

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

8. Conclusion

8.结论

In this article, we learned how to authorize our application using OAuth2.

在这篇文章中,我们学习了如何使用OAuth2对我们的应用程序进行授权。

The full implementation of this tutorial can be found in the GitHub project.

本教程的完整实现可以在GitHub项目中找到。