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

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

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

在本文中,我们继续讨论如何在“单页应用程序”中将Spring SecurityAngular JS一起使用。在这里,我们展示了如何将Spring SessionSpring Cloud结合使用,以结合我们在第二部分和第四部分构建的系统的功能,并最终构建 3 个具有完全不同职责的单页应用程序。目的是构建一个网关(如第四部分中所示),它不仅用于 API 资源,还用于从后端服务器加载 UI。我们通过使用网关将身份验证传递到后端,简化了第二部分中令牌处理的部分。然后,我们扩展系统以展示如何在后端进行本地、细粒度的访问决策,同时仍然在网关处控制身份和身份验证。这是一种用于构建分布式系统的非常强大的模型,并且具有一些好处,我们可以在引入我们构建的代码中的功能时进行探索。

提醒:如果您正在使用示例应用程序阅读本文,请确保清除浏览器缓存中的 Cookie 和 HTTP 基本凭据。在 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 转换到我们这里想要的 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 作为身份验证令牌,但除非 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: https://127.0.0.1:8081
    resource:
      url: https://127.0.0.1:9000
security:
  user:
    password:
      password
  sessions: ALWAYS

代理中有 2 条路由,UI 和资源服务器各一条,我们设置了默认密码和会话持久性策略(告诉 Spring Security 在身份验证时始终创建会话)。最后一点很重要,因为我们希望在网关中管理身份验证和会话。

运行起来

我们现在有三个组件,运行在 3 个端口上。如果你将浏览器指向 https://127.0.0.1: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,每个服务器都具有独立的功能并且能够独立测试,并且它们通过一个你控制的并且你已配置身份验证的安全网关连接在一起。如果后端无法访问浏览器,则无关紧要(实际上,这可能是一个优势,因为它让你对物理安全性有了更多的控制)。

添加登录表单

就像在 第一部分 的“基本”示例中一样,我们现在可以向网关添加登录表单,例如,通过复制来自 第二部分 的代码。当我们这样做时,我们还可以向网关添加一些基本导航元素,以便用户不必知道代理中 UI 后端的路径。因此,让我们首先将“单一”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 中的示例,它还有一个带有“注销”按钮的最小导航栏。以下是在屏幕截图中的登录表单

Login Page

为了支持登录表单,我们需要一些 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 中的内容,并且我们希望将对该功能的访问限制为具有特殊角色的用户。因此,我们将在代理后面添加一个“管理员”应用程序,并且系统将如下所示

Components of the System

application.yml 中有一个新的组件(管理员)和网关中的一个新路由

zuul:
  routes:
    ui:
      url: https://127.0.0.1:8081
    admin:
      url: https://127.0.0.1:8082
    resource:
      url: https://127.0.0.1:9000

现有 UI 可供“USER”角色中的用户使用的事实如上图网关框(绿色字母)中所示,以及访问“ADMIN”应用程序需要“ADMIN”角色的事实。对“ADMIN”角色的访问决策可以在网关中应用,在这种情况下它将出现在 WebSecurityConfigurerAdapter 中,或者它可以在管理员应用程序本身中应用(我们将在下面看到如何做到这一点)。

此外,假设在管理员应用程序中,我们希望区分“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”角色(此后端全局需要),我们在 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”端点,然后提取一些数据,设置已认证标志,并且如果用户已认证,则通过查看用户数据来计算模板。

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

@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 的一项功能)。因此,“ROLE_”前缀在 JavaScript 中是必需的,但在 Spring Security 配置中不是必需的,因为从方法名称可以清楚地看出“角色”是操作的重点。

我们为什么在这里?

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

一个强大的功能是后端可以独立地拥有任何他们喜欢的身份验证类型(例如,如果你知道它的物理地址和一组本地凭据,则可以直接访问 UI)。网关强加了一组完全无关的约束,只要它能够对用户进行身份验证并向他们分配满足后端访问规则的元数据即可。这对于能够独立开发和测试后端组件来说是一个极好的设计。如果需要,我们可以回到外部 OAuth2 服务器(如 第五部分 中那样,甚至完全不同的东西)进行网关身份验证,并且不需要触及后端。

此架构的一个额外功能(单个网关控制身份验证,并在所有组件之间共享会话令牌)是“单点注销”,我们在 第五部分 中确定为难以实现的功能,现在可以免费获得。更准确地说,我们完成的系统中自动提供了单点注销的用户体验的一种特定方法:如果用户从任何 UI(网关、UI 后端或管理员后端)注销,则他将从所有其他 UI 注销,假设每个单独的 UI 以相同的方式实现了“注销”功能(使会话失效)。

如果你仍然觉得有趣,请尝试本系列的 下一篇文章,该文章主要关于 Javascript,但仍然展示了 Spring 后端如何使事情变得更容易。

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

获取 Spring Newsletter

关注 Spring Newsletter

订阅

领先一步

VMware 提供培训和认证,助您加速发展。

了解更多

获取支持

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

了解更多

即将举行的活动

查看 Spring 社区所有即将举行的活动。

查看全部