多个 UI 应用和一个网关:Spring 和 Angular JS 的单页应用 第六部分

工程 | Dave Syer | 2015 年 3 月 23 日 | ...

注意:此博客的源代码和测试仍在不断发展,但文本的更改在此处未进行维护。请参阅教程版本以获取最新内容。

在本文中,我们将继续讨论如何在“单页应用”中使用 Spring SecurityAngular JS。这里我们将展示如何结合使用 Spring SessionSpring Cloud 来整合我们在第二部分和第四部分构建的系统的功能,最终实际上构建了 3 个职责迥异的单页应用。目标是构建一个网关(就像在 第四部分 中一样),该网关不仅用于 API 资源,还用于从后端服务器加载 UI。通过使用网关将认证传递到后端,我们简化了 第二部分 中的令牌处理部分。然后,我们扩展系统以展示如何在后端进行本地、细粒度的访问决策,同时仍在网关层面控制身份和认证。这是一种非常强大的构建分布式系统的通用模型,并具有我们在介绍构建的代码中的功能时可以探索的许多优点。

提示:如果您正在结合示例应用阅读本文,请务必清除浏览器的 cookie 和 HTTP Basic 凭据缓存。在 Chrome 中,最好的方法是打开新的隐身窗口。

目标架构

这是我们开始构建的基本系统图示

Components of the System

与本系列中的其他示例应用一样,它有一个 UI(HTML 和 JavaScript)和一个资源服务器。就像 第四部分 中的示例一样,它有一个网关,但在这里它是独立的,不是 UI 的一部分。UI 实际上成为了后端的一部分,这使得我们可以更自由地重新配置和重新实现功能,并且还会带来其他好处,正如我们将看到的。

浏览器访问网关以获取所有内容,它不必了解后端的架构(从根本上说,它不知道有后端存在)。浏览器在此网关中执行的操作之一是认证,例如,它像在 第二部分 中那样发送用户名和密码,并获得一个 cookie 作为回报。在后续请求中,它会自动呈现 cookie,网关将其传递给后端。客户端无需编写任何代码来启用 cookie 传递。后端使用 cookie 进行认证,并且由于所有组件共享一个会话,它们共享相同的用户信息。这与 第五部分 不同,第五部分中 cookie 必须在网关中转换为访问令牌,然后所有后端组件必须独立解码访问令牌。

正如 第四部分 中一样,网关简化了客户端和服务器之间的交互,并提供了一个小型、定义良好的界面来处理安全性。例如,我们无需担心跨域资源共享,这令人欣慰,因为它很容易出错。

我们将构建的完整项目的源代码在此Github中,所以如果您愿意,可以直接克隆项目并从那里开始工作。此系统最终状态中还有一个额外的组件("double-admin"),现在可以忽略它。

构建后端

在此架构中,后端与我们在 第三部分 构建的 "spring-session" 示例非常相似,唯一的区别是它实际上不需要登录页面。要达到我们想要的结果,最简单的方法可能是从第三部分复制“resource”服务器,并从 第一部分"basic" 示例中获取 UI。要从“basic” UI 得到我们想要的,我们只需要添加几个依赖(就像我们在第三部分第一次使用 Spring Session 时一样)

<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session</artifactId>
  <version>1.0.0.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-redis</artifactId>
</dependency>

并将 @EnableRedisHttpSession 注解添加到主应用类

@SpringBootApplication
@EnableRedisHttpSession
public class UiApplication {

public static void main(String[] args) {
    SpringApplication.run(UiApplication.class, args);
  }

}

既然这现在是一个 UI,就不再需要 "/resource" 端点。完成这些后,您将拥有一个非常简单的 Angular 应用(与“basic”示例中的相同),这大大简化了对其行为的测试和推理。

最后,我们希望这个服务器作为后端运行,因此我们将为其分配一个非默认端口用于监听(在 application.properties 中)

server.port: 8081
security.sessions: NEVER

如果这是 application.properties全部内容,那么该应用将是安全的,并可供名为 "user" 的用户访问,密码是随机的,但会在启动时打印在控制台上(INFO 级别的日志)。"security.sessions" 设置意味着 Spring Security 将接受 cookie 作为认证令牌,但除非它们已经存在,否则不会创建它们。

资源服务器

资源服务器很容易从我们现有的一个示例生成。它与 第三部分 中的 "spring-session" 资源服务器相同:只有一个 "/resource" 端点和 @EnableRedisHttpSession 来获取分布式会话数据。我们希望这个服务器使用非默认端口进行监听,并且能够在会话中查找认证信息,因此我们需要在 application.properties 中添加以下内容

server.port: 9000
security.sessions: NEVER

如果您想看一眼,完整的示例在此github中。

网关

对于网关的初始实现(最简单可行的方案),我们可以只获取一个空的 Spring Boot web 应用并添加 @EnableZuulProxy 注解。正如我们在 第一部分 中看到的,有几种方法可以做到这一点,其中一种是使用 Spring Initializr 生成一个骨架项目。更简单的是使用 Spring Cloud Initializr,它与 Spring Initializr 相同,但用于 Spring Cloud 应用。使用与第一部分相同的命令行操作序列

$ mkdir gateway && cd gateway
$ curl https://cloud-start.spring.io/starter.tgz -d style=web \
  -d style=security -d style=cloud-zuul -d name=gateway \
  -d style=redis | tar -xzvf - 

然后您可以将该项目(默认情况下是一个普通的 Maven Java 项目)导入到您喜欢的 IDE 中,或者只使用文件并在命令行上运行 "mvn"。如果您想从那里开始,github 中有一个版本,但它包含一些我们暂时不需要的额外功能。

从空白的 Initializr 应用开始,我们添加 Spring Session 依赖(就像上面的 UI 中一样),以及 @EnableRedisHttpSession 注解

@SpringBootApplication
@EnableRedisHttpSession
@EnableZuulProxy
public class GatewayApplication {

public static void main(String[] args) {
    SpringApplication.run(GatewayApplication.class, args);
  }

}

网关已准备就绪,但它尚不知道我们的后端服务,所以我们只需在其 application.yml 中进行设置(如果您执行了上面的 curl 操作,则从 application.properties 重命名)

zuul:
  routes:
    ui:
      url: http://localhost:8081
    resource:
      url: http://localhost:9000
security:
  user:
    password:
      password
  sessions: ALWAYS

代理中有 2 个路由,一个用于 UI,一个用于资源服务器,并且我们设置了一个默认密码和一个会话持久化策略(告诉 Spring Security 在认证时总是创建会话)。最后这一点很重要,因为我们希望认证和会话在网关中进行管理。

启动并运行

现在我们有三个组件,运行在 3 个端口上。如果您将浏览器指向 http://localhost:8080/ui/,您应该会收到一个 HTTP Basic 挑战,您可以以 "user/password"(您在网关中的凭据)进行认证,完成认证后,您应该能在 UI 中看到一个问候语,这是通过代理对资源服务器的后端调用。

如果您使用开发者工具(通常按 F12 打开,Chrome 默认支持,Firefox 需要插件),可以在浏览器中看到浏览器和后端之间的交互。以下是摘要

动词 路径 状态 响应
GET /ui/ 401 浏览器提示认证
GET /ui/ 200 index.html
GET /ui/css/angular-bootstrap.css 200 Twitter bootstrap CSS
GET /ui/js/angular-bootstrap.js 200 Bootstrap 和 Angular JS
GET /ui/js/hello.js 200 应用逻辑
GET /ui/user 200 认证
GET /resource/ 200 JSON 问候语

您可能看不到 401 错误,因为浏览器将主页加载视为一次单一交互。所有请求都被代理(网关中除了用于管理的 Actuator 端点外,暂时没有其他内容)。

成功了,它能工作了!您现在有两个后端服务器,其中一个是 UI,每个服务器都具有独立的功能并且能够独立测试,它们通过一个安全网关连接在一起,该网关由您控制并且您已为其配置了认证。如果后端浏览器无法直接访问,这并不重要(事实上,这可能是一个优势,因为它让您对物理安全有了更多控制)。

添加登录表单

正如 第一部分 中的“basic”示例一样,我们现在可以向网关添加一个登录表单,例如通过复制 第二部分 的代码。这样做时,我们还可以在网关中添加一些基本的导航元素,这样用户就不必知道代理中 UI 后端的路径。因此,首先我们将“single” UI 的静态资源复制到网关中,删除消息渲染并在我们的主页中(在 <body/> 的某个位置)插入一个登录表单

<body ng-app="hello" ng-controller="navigation" ng-cloak
	class="ng-cloak">
  ...
  <div class="container" ng-show="!authenticated">
    <form role="form" ng-submit="login()">
      <div class="form-group">
        <label for="username">Username:</label> <input type="text"
          class="form-control" id="username" name="username"
          ng-model="credentials.username" />
      </div>
      <div class="form-group">
        <label for="password">Password:</label> <input type="password"
          class="form-control" id="password" name="password"
          ng-model="credentials.password" />
      </div>
      <button type="submit" class="btn btn-primary">Submit</button>
    </form>
  </div>
</body>

我们将有一个漂亮的、大的导航按钮来替代消息渲染

<div class="container" ng-show="authenticated">
  <a class="btn btn-primary" href="/ui/">Go To User Interface</a>
</div>

如果您正在查看 github 中的示例,它还有一个带有“Logout”按钮的最小导航栏。这是登录表单的截图

Login Page

为了支持登录表单,我们需要一些带有“navigation”控制器的 JavaScript,它实现我们在 <form/> 中声明的 login() 函数,并且我们需要设置 authenticated 标志,以便主页根据用户是否已认证呈现不同的内容。例如

angular.module('hello', []).controller('navigation',
function($scope, $http) {

  ...
  
  authenticate();
  
  $scope.credentials = {};

$scope.login = function() {
    authenticate($scope.credentials, function() {
      if ($scope.authenticated) {
        console.log("Login succeeded")
        $scope.error = false;
        $scope.authenticated = true;
      } else {
        console.log("Login failed")
        $scope.error = true;
        $scope.authenticated = false;
      }
    })
  };

}

其中 authenticate() 函数的实现与 第二部分 中的类似

var authenticate = function(credentials, callback) {

  var headers = credentials ? {
    authorization : "Basic "
        + btoa(credentials.username + ":"
            + credentials.password)
  } : {};

  $http.get('user', {
    headers : headers
  }).success(function(data) {
    if (data.name) {
      $scope.authenticated = true;
    } else {
      $scope.authenticated = false;
    }
    callback && callback();
  }).error(function() {
    $scope.authenticated = false;
    callback && callback();
  });

}

我们可以使用 $scope 来存储 authenticated 标志,因为这个简单的应用中只有一个控制器。

如果我们运行这个增强的网关,就无需记住 UI 的 URL,只需加载主页并跟随链接即可。这是一张认证用户的主页截图

Home Page

后端中的细粒度访问决策

到目前为止,我们的应用功能上与 第三部分第四部分 中的非常相似,但增加了一个专用的网关层。额外这一层的好处可能尚不明显,但我们可以通过稍微扩展系统来强调它。假设我们想使用该网关暴露另一个后端 UI,供用户“管理”主 UI 中的内容,并且我们希望将此功能的访问权限限制给具有特殊角色的用户。因此,我们将在代理后面添加一个“Admin”应用,系统将如下所示

Components of the System

网关的 application.yml 中新增了一个组件(Admin)和一个新路由

zuul:
  routes:
    ui:
      url: http://localhost:8081
    admin:
      url: http://localhost:8082
    resource:
      url: http://localhost:9000

现有 UI 可供具有 "USER" 角色的用户访问的事实已在上面的框图中的网关框中(绿色文字)标明,同样,访问 Admin 应用需要 "ADMIN" 角色。对 "ADMIN" 角色的访问决策可以在网关中应用,在这种情况下它将出现在 WebSecurityConfigurerAdapter 中,或者可以在 Admin 应用本身中应用(我们将在下面看到如何操作)。

此外,假设在 Admin 应用中我们想区分 "READER" 和 "WRITER" 角色,以便允许(例如)审计用户查看主管理员用户所做的更改。这是一个细粒度的访问决策,规则仅在后端应用中已知,也只应在后端应用中已知。在网关中,我们只需要确保我们的用户账户拥有所需的角色,并且此信息可用,但网关无需知道如何解释它。为了使示例应用自包含,我们在网关中创建用户账户

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

  @Autowired
  public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
      .withUser("user").password("password").roles("USER")
    .and()
      .withUser("admin").password("admin").roles("USER", "ADMIN", "READER", "WRITER")
    .and()
      .withUser("audit").password("audit").roles("USER", "ADMIN", "READER");
  }
  
}

其中“admin”用户已增强了 3 个新角色("ADMIN"、"READER" 和 "WRITER"),我们还添加了一个具有 "ADMIN" 访问权限但没有 "WRITER" 权限的“audit”用户。

旁白:在生产系统中,用户账户数据将在后端数据库(很可能是目录服务)中进行管理,而不是硬编码在 Spring 配置中。连接此类数据库的示例应用很容易在互联网上找到,例如在 Spring Security 示例中。

访问决策在 Admin 应用中进行。对于“ADMIN”角色(此后端全局需要),我们在 Spring Security 中进行处理

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

@Override
  protected void configure(HttpSecurity http) throws Exception {
    http
    ...
      .authorizeRequests()
        .antMatchers("/index.html", "/login", "/").permitAll()
        .antMatchers("/admin/**").hasRole("ADMIN")
        .anyRequest().authenticated()
    ...
  }
  
}

对于“READER”和“WRITER”角色,应用本身是分开的,并且由于应用是用 JavaScript 实现的,所以我们需要在那里做出访问决策。一种方法是创建一个包含嵌入式计算视图的主页

<div class="container">
  <h1>Admin</h1>
  <div ng-show="authenticated" ng-include="template"></div>
  <div ng-show="!authenticated" ng-include="'unauthenticated.html'"></div>
</div>

Angular JS 将 "ng-include" 属性值评估为一个表达式,然后使用结果加载模板。

提示:更复杂的应用可能会使用其他机制进行模块化,例如我们在本系列几乎所有其他应用中使用的 $routeProvider 服务。

template 变量在我们的控制器中初始化,首先通过定义一个工具函数

var computeDefaultTemplate = function(user) {
  $scope.template = user && user.roles
      && user.roles.indexOf("ROLE_WRITER")>0 ? "write.html" : "read.html";		
}

然后在控制器加载时使用该工具函数

angular.module('admin', []).controller('home',

function($scope, $http) {
	
  $http.get('user').success(function(data) {
    if (data.name) {
      $scope.authenticated = true;
      $scope.user = data;
      computeDefaultTemplate(data);
    } else {
      $scope.authenticated = false;
    }
    $scope.error = null
  })
  ...
      
})

应用做的第一件事是查看本系列通常使用的 "/user" 端点,然后提取一些数据,设置 authenticated 标志,如果用户已认证,则通过查看用户数据计算模板。

为了在后端支持此功能,我们需要一个端点,例如在我们的主应用类中

@SpringBootApplication
@RestController
@EnableRedisHttpSession
public class AdminApplication {

  @RequestMapping("/user")
  public Map<String, Object> user(Principal user) {
    Map<String, Object> map = new LinkedHashMap<String, Object>();
    map.put("name", user.getName());
    map.put("roles", AuthorityUtils.authorityListToSet(((Authentication) user)
        .getAuthorities()));
    return map;
  }

  public static void main(String[] args) {
    SpringApplication.run(AdminApplication.class, args);
  }

}

注意:角色名称从 "/user" 端点返回时带有 "ROLE_" 前缀,以便与其它类型的权限区分开来(这是 Spring Security 的特性)。因此,JavaScript 中需要 "ROLE_" 前缀,但在 Spring Security 配置中则不需要,后者的方法名称已明确表明“roles”是操作的重点。

我们为何在此?

现在我们有了一个不错的小系统,它包含 2 个独立的 UI 和一个后端资源服务器,所有这些都由网关中的相同认证进行保护。网关充当微代理这一事实使得后端安全问题的实现极其简单,并且它们可以自由地专注于自己的业务问题。Spring Session 的使用(再次)避免了大量的麻烦和潜在错误。

一个强大的特性是后端可以独立地拥有它们喜欢的任何类型的认证方式(例如,如果您知道 UI 的物理地址和一组本地凭据,您可以直接访问它)。网关施加了一组完全无关的约束,只要它能够认证用户并为其分配满足后端访问规则的元数据。这对于能够独立开发和测试后端组件来说是一个出色的设计。如果愿意,我们可以回到使用外部 OAuth2 服务器(如 第五部分 中所述,甚至是完全不同的东西)来进行网关层的认证,而后端则无需改动。

此架构(单一网关控制认证,并在所有组件之间共享会话令牌)的一个额外特性是“Single Logout”(单点注销),我们在 第五部分 中指出该功能难以实现,但在此处它是免费获得的。更确切地说,我们的最终系统中自动提供了一种特定的单点注销用户体验方法:如果用户从任何一个 UI(网关、UI 后端或 Admin 后端)注销,他也会从所有其他 UI 注销,前提是每个独立的 UI 都以相同的方式实现了“logout”功能(使会话失效)。

如果您仍然乐在其中,请尝试本系列下一篇文章,该文章主要介绍 Javascript,但仍然展示了 Spring 后端如何简化事情。

致谢:我再次感谢所有帮助我开发本系列的人,特别是 Rob WinchThorsten Späth 对文章和源代码的认真审阅。自 第一部分 发表以来,它变化不大,但所有其他部分都根据读者的评论和见解进行了改进,所以也感谢所有阅读了文章并积极参与讨论的人。

订阅 Spring 资讯

保持与 Spring 资讯的连接

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获取支持

通过一个简单的订阅,Tanzu Spring 为 OpenJDK™、Spring 和 Apache Tomcat® 提供支持和二进制文件。

了解更多

近期活动

查看 Spring 社区的所有近期活动。

查看全部