1. 概述

本教程中,我们将使用OAuth2来保护我们的后端REST API,并使用Angular作为我们的前端

我要构建的应用包含3个独立的模块:

  • Authorization Server(授权服务器)
  • Resource Server(资源服务器)
  • UI authorization code:我们的前端应用,使用授权码模式认证流程。

我们将使用新的Spring Security 5 OAuth技术栈实现。 如果你想使用老版本Spring Security OAuth,可以看下我们之前文章:Spring REST API + OAuth2 + Angular (使用过时的Spring Security OAuth)

下面进入正题。

2. OAuth2 授权服务器 Authorization Server (AS)

简单来说,Authorization Server是用于认证,并颁发授权token的应用程序

在老版本的 Spring Security OAuth中,我们可以自己搭建一个Authorization Server的Spring应用。但现在已不建议这么做,主要是因为OAuth是一个标准开放的协议,已经有很多现成的服务可以直接拿来使用,例如Okta,Keycloak和ForgeRock等。

其中,我们将使用Keycloak。Keycloak是一款开源的身份认证和控制访问服务器。由Red Hat主导,使用Java和JBoss开发。它不仅支持OAuth2,还支持其他标准协议,例如OpenID Connect和SAML。

对于本教程,**我们将在Spring Boot中搭建一个嵌入式的Keycloak服务器**。

3. 资源服务器 Resource Server (RS)

现在让我们讨论Resource Server所谓的资源,实际上它就是我们的REST API。

3.1. Maven 配置

Resource Serverpom配置和Authorization Server几乎相同,只是没有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,所以只需在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 token进行授权认证。

jwk-set-uri属性指向包含公钥的URI,以便我们的Resource Server可以验证token的完整性。

issuer-uri属性表示一种用于验证token发行者(即Authorization Server)的附加安全措施。 但是,添加此属性要求必须先运行Authorization Server,然后才能启动资Resource Server。

下一步,配置Security以保护我们的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请求,除了read还需要write写权限。对于任何接口,用户都需要认证。

同样,oauth2ResourceServer()方法指明了这是一个Resource Server,使用jwt格式的token。

另外需要注意的是,使用了cors()方法,以允许Access-Control请求头。这一点特别重要,因为我们正在使用Angular客户端,而我们的请求将来自另一个源URL。

3.4. Model(模型) 与 Repository(数据层)

下面,为我们的模型Foo定义javax.persistence.Entity实体类,

@Entity
public class Foo {

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

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

然后需要添加Foo对应的Repository。我们将使用Spring的 PagingAndSortingRepository:

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

3.4. Service实现

之后,我们需要定义Service层:

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. 一个简单的Controller

现在定义一个简单的controller, 通过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注解。这是Controller级的配置,我们需要允许来自Angular App的跨域请求。

下面是我们的FooDto:

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

4. 前端 — 配置

现在我们看下前端客户端Angular的实现,它将调用我们的REST API。

我们首先使用Angular CLI工具生成和管理我们前端模块。

**首先,我们要安装node 和 npm**,因为Angular CLI是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>

最后, 用Angular CLI生成新模块

ng new oauthApp

下面章节,我们将讨论Angular应用逻辑

5. 使用Angular完成授权码模式(Authorization Code)认证流程

我们准备使用OAuth2授权码模式。

整个认证逻辑:客户端向Authorization Server请求授权码,并显示登录页面。 一旦用户提交了有效的凭据(账号密码),Authorization Server就会向我们返回授权码。 然后,前端客户端使用授权码来获取access token。

5.1. Home 组件

先从我们最主要的组件 - 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();
  }
}

首先,当用户未登录时,仅显示登录按钮。 用户单击登录按钮后,跳转到Authorization Server授权页面,在其中输入用户名和密码。成功登录后,重定向回去并附带授权码,然后我们使用授权码获取access token。

5.2. App Service

现在让我们看一下AppService(位于app.service.ts),里面包含了与服务器交互的登录逻辑:

  • retrieveToken(): 使用授权码获取access token
  • saveToken(): 使用ng2-cookies保存access token
  • getResource(): 通过ID从服务器查询Foo对象
  • checkCredentials(): 判断用户是否登录
  • logout(): 用户登出后删除access token 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方法中, 我们使用用户凭据(账号密码)以Basic认证方式向/openid-connect/token发送POST请求,以获取access token。 参数以URL编码方式发送。 得到access token后, 我们把它存储在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. App 组件

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端口上。要更改,在package.json中将

"start": "ng serve"

修改为,比如8089:

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

8. 总结

在本文中,我们学习了如何使用OAuth2对应用程序进行授权认证。

惯例,本文完整源代码可从GitHub上获取。