使用 OAuth2 的 SSO:Angular JS 和 Spring Security 第五部分

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

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

在本文中,我们继续讨论如何在“单页应用程序”中将Spring SecurityAngular JS结合使用。在这里,我们将展示如何将Spring Security OAuthSpring Cloud结合使用,以扩展我们的 API 网关,实现单点登录和对后端资源的 OAuth2 令牌身份验证。这是系列文章中的第五篇,您可以了解应用程序的基本构建块或通过阅读第一篇文章从头开始构建它,或者您可以直接转到Github 中的源代码。在上一篇文章中,我们构建了一个小型分布式应用程序,该应用程序使用Spring Session对后端资源进行身份验证,并使用Spring Cloud在 UI 服务器中实现嵌入式 API 网关。在本文中,我们将身份验证职责提取到单独的服务器,以使我们的 UI 服务器成为可能连接到授权服务器的众多单点登录应用程序中的第一个。这在当今许多应用程序中都是一种常见的模式,无论是在企业中还是在社交创业公司中。我们将使用 OAuth2 服务器作为身份验证器,以便我们也可以使用它来为后端资源服务器授予令牌。Spring Cloud 会自动将访问令牌转发到我们的后端,并使我们能够进一步简化 UI 和资源服务器的实现。

提醒:如果您正在使用示例应用程序阅读本文,请确保清除浏览器缓存中的 Cookie 和 HTTP 基本凭据。在 Chrome 中,对单个服务器执行此操作的最佳方法是打开一个新的隐身窗口。

创建 OAuth2 授权服务器

我们的第一步是创建一个新的服务器来处理身份验证和令牌管理。按照第一部分中的步骤,我们可以从Spring Boot Initializr开始。例如,在类似 UN*X 的系统上使用 curl

$ curl https://start.spring.io/starter.tgz -d style=web \
-d style=security -d name=authserver | tar -xzvf - 

然后,您可以将该项目(默认情况下是普通的 Maven Java 项目)导入到您喜欢的 IDE 中,或者只使用文件和命令行上的“mvn”。

添加 OAuth2 依赖项

我们需要添加Spring OAuth依赖项,因此在我们的POM中,我们添加

<dependency>
  <groupId>org.springframework.security.oauth</groupId>
  <artifactId>spring-security-oauth2</artifactId>
  <version>2.0.5.RELEASE</version>
</dependency>

授权服务器非常容易实现。最小版本如下所示

@SpringBootApplication
public class AuthserverApplication extends WebMvcConfigurerAdapter {

  public static void main(String[] args) {
    SpringApplication.run(AuthserverApplication.class, args);
  }
  
  @Configuration
  @EnableAuthorizationServer
  protected static class OAuth2Config extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private AuthenticationManager authenticationManager;
    
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
      endpoints.authenticationManager(authenticationManager);
    }

@Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
      clients.inMemory()
          .withClient("acme")
          .secret("acmesecret")
          .authorizedGrantTypes("authorization_code", "refresh_token",
              "password").scopes("openid");
    }

}

我们只需要做两件事(在添加@EnableAuthorizationServer之后)

  • 注册一个名为“acme”的客户端,并为其设置一个密钥和一些授权授予类型,包括“authorization_code”。

  • 从 Spring Boot 自动配置中注入默认的AuthenticationManager,并将其连接到 OAuth2 端点。

现在让我们在端口 9999 上运行它,并使用可预测的密码进行测试

server.port=9999
security.user.password=password
server.contextPath=/uaa

我们还设置了上下文路径,使其不使用默认值(“/”),因为否则您可能会将其他服务器在 localhost 上的 Cookie 发送到错误的服务器。因此,启动服务器,我们可以确保它正在工作

$ mvn spring-boot:run

或在您的 IDE 中启动main()方法。

测试授权服务器

我们的服务器正在使用 Spring Boot 的默认安全设置,因此与第一部分中的服务器一样,它将受到 HTTP 基本身份验证的保护。要启动授权码令牌授予,您需要访问授权端点,例如https://127.0.0.1:9999/uaa/oauth/authorize?response_type=code&client_id=acme&redirect_uri=http://example.com,一旦您完成身份验证,您将获得一个重定向到 example.com 并附加授权码的重定向,例如http://example.com/?code=jYWioI

注意:出于此示例应用程序的目的,我们创建了一个名为“acme”的客户端,但未注册重定向,这使我们能够获得重定向到 example.com。在生产应用程序中,您应始终注册重定向(并使用 HTTPS)。

可以使用令牌端点上的“acme”客户端凭据将代码交换为访问令牌

$ curl acme:acmesecret@localhost:9999/uaa/oauth/token  \
-d grant_type=authorization_code -d client_id=acme     \
-d redirect_uri=http://example.com -d code=jYWioI
{"access_token":"2219199c-966e-4466-8b7e-12bb9038c9bb","token_type":"bearer","refresh_token":"d193caf4-5643-4988-9a4a-1c03c9d657aa","expires_in":43199,"scope":"openid"}

访问令牌是一个 UUID(“2219199c...”),由服务器中的内存令牌存储支持。我们还获得了刷新令牌,当当前令牌过期时,我们可以使用它来获取新的访问令牌。

注意:由于我们允许“acme”客户端使用“password”授予,因此我们还可以使用 curl 和用户凭据从令牌端点直接获取令牌,而不是使用授权码。这对于基于浏览器的客户端不适用,但对于测试很有用。

如果您按照上面的链接操作,您将看到 Spring OAuth 提供的白色标签 UI。首先,我们将使用它,稍后我们可以像在第二部分中为自包含服务器所做的那样,对其进行增强。

更改资源服务器

如果我们继续从第四部分开始,我们的资源服务器正在使用Spring Session进行身份验证,因此我们可以将其删除并替换为 Spring OAuth。我们还需要删除 Spring Session 和 Redis 依赖项,因此将此

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

替换为以下内容

<dependency>
  <groupId>org.springframework.security.oauth</groupId>
  <artifactId>spring-security-oauth2</artifactId>
</dependency>

然后从主应用程序类中删除会话Filter,并将其替换为方便的@EnableOAuth2Resource注释(来自 Spring Cloud Security)

@SpringBootApplication
@RestController
@EnableOAuth2Resource
class ResourceApplication {

  @RequestMapping('/')
  def home() {
    [id: UUID.randomUUID().toString(), content: 'Hello World']
  }

  static void main(String[] args) {
    SpringApplication.run ResourceApplication, args
  }
}

这足以让我们获得受保护的资源。运行应用程序并使用命令行客户端访问主页

$ curl -v localhost:9000
> GET / HTTP/1.1
> User-Agent: curl/7.35.0
> Host: localhost:9000
> Accept: */*
> 
< HTTP/1.1 401 Unauthorized
...
< WWW-Authenticate: Bearer realm="null", error="unauthorized", error_description="An Authentication object was not found in the SecurityContext"
< Content-Type: application/json;charset=UTF-8
{"error":"unauthorized","error_description":"An Authentication object was not found in the SecurityContext"}

您将看到一个 401,以及指示需要承载令牌的“WWW-Authenticate”标头。我们将添加少量外部配置(在“application.properties”中),以允许资源服务器解码它收到的令牌并对用户进行身份验证

...
spring.oauth2.resource.userInfoUri: https://127.0.0.1:9999/uaa/user

这告诉服务器它可以使用令牌访问“/user”端点,并使用该端点派生身份验证信息(这有点像 Facebook API 中的"/me"端点)。实际上,它为资源服务器提供了一种解码令牌的方法,如 Spring OAuth2 中的ResourceServerTokenServices接口所表达的那样。

注意:userInfoUri 绝不是将资源服务器与解码令牌的方式挂钩的唯一方法。事实上,它更像是一种最低公分母(并且不是规范的一部分),但通常可以从 OAuth2 提供商(如 Facebook、Cloud Foundry、Github)获得,并且还有其他选择。例如,您可以将用户身份验证编码到令牌本身(例如使用 JWT),或使用共享的后端存储。CloudFoundry 中还有一个 /token_info 端点,它提供了比用户信息端点更详细的信息,但需要更彻底的身份验证。不同的选项(自然地)提供了不同的好处和权衡,但本文档不讨论这些内容。

实现用户端点

在授权服务器上,我们可以轻松添加该端点。

@SpringBootApplication
@RestController
@EnableResourceServer
public class AuthserverApplication {

  @RequestMapping("/user")
  public Principal user(Principal user) {
    return user;
  }

  ...

}

我们添加了一个 @RequestMapping,与 第二部分 中的 UI 服务器相同,以及来自 Spring OAuth 的 @EnableResourceServer 注解,默认情况下,它会保护授权服务器中的所有内容,除了 "/oauth/*" 端点。

有了该端点,我们就可以对其进行测试以及测试问候资源,因为它们现在都接受由授权服务器创建的 Bearer 令牌。

$ TOKEN=2219199c-966e-4466-8b7e-12bb9038c9bb
$ curl -H "Authorization: Bearer $TOKEN" localhost:9000
{"id":"03af8be3-2fc3-4d75-acf7-c484d9cf32b1","content":"Hello World"}
$ curl -H "Authorization: Bearer $TOKEN" localhost:9999/uaa/user
{"details":...,"principal":{"username":"user",...},"name":"user"}

(替换从您自己的授权服务器获得的访问令牌的值,以便自行运行)。

UI 服务器

我们需要完成此应用程序的最后一部分是 UI 服务器,提取身份验证部分并委托给授权服务器。因此,与 资源服务器 一样,我们首先需要删除 Spring Session 和 Redis 依赖项,并将其替换为 Spring OAuth2。

完成此操作后,我们还可以删除会话过滤器和 "/user" 端点,并设置应用程序以重定向到授权服务器(使用 @EnableOAuth2Sso 注解)。

@SpringBootApplication
@EnableZuulProxy
@EnableOAuth2Sso
public class UiApplication {

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

  @Configuration
  @Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
  protected static class SecurityConfiguration extends WebSecurityConfigurerAdapter {

回想 第四部分,UI 服务器凭借 @EnableZuulProxy 充当 API 网关,我们可以在 YAML 中声明路由映射。因此,"/user" 端点可以代理到授权服务器。

zuul:
  routes:
    resource:
      path: /resource/**
      url: https://127.0.0.1:9000
    user:
      path: /user/**
      url: https://127.0.0.1:9999/uaa/user

最后,我们需要将 WebSecurityConfigurerAdapter 更改为 OAuth2SsoConfigurerAdapter,因为现在它将用于修改由 @EnableOAuth2Sso 设置的 SSO 过滤器链中的默认值。

  @Configuration
  protected static class SecurityConfiguration extends OAuth2SsoConfigurerAdapter {

    @Override
    public void match(RequestMatchers matchers) {
      matchers.anyRequest();
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
      http.authorizeRequests().antMatchers("/index.html", "/home.html", "/")
          .permitAll().anyRequest().authenticated().and().csrf()
          .csrfTokenRepository(csrfTokenRepository()).and()
          .addFilterAfter(csrfHeaderFilter(), CsrfFilter.class);
    }
    
    ... // the csrf*() methods are the same as the old WebSecurityConfigurerAdapter
  }

主要更改(除了基类名称之外)是匹配器进入它们自己的方法,并且不再需要 formLogin()

@EnableOAuth2Sso 注解还需要一些强制性的外部配置属性才能与正确的授权服务器联系并进行身份验证。因此,我们需要在 application.yml 中添加以下内容。

spring:
  oauth2:
    sso:
      home:
        secure: false
        path: /,/**/*.html
    client:
      accessTokenUri: https://127.0.0.1:9999/uaa/oauth/token
      userAuthorizationUri: https://127.0.0.1:9999/uaa/oauth/authorize
      clientId: acme
      clientSecret: acmesecret
    resource:
      userInfoUri: https://127.0.0.1:9999/uaa/user

大部分内容与 OAuth2 客户端(“acme”)和授权服务器位置有关。还有一个 userInfoUri(就像在资源服务器中一样),以便用户可以在 UI 应用程序本身进行身份验证。“home” 部分与允许匿名访问我们单页应用程序中的静态资源有关。

在客户端中

前端的 UI 应用程序还有一些小的调整,我们需要进行这些调整才能触发重定向到授权服务器。第一个是在 "index.html" 中的导航栏中,“登录”链接从 Angular 路由更改为

<div ng-controller="navigation" class="container">
  <ul class="nav nav-pills" role="tablist">
    ...
    <li><a href="#/login">login</a></li>
    ...
  </ul>
</div>

普通 HTML 链接。

<div ng-controller="navigation" class="container">
  <ul class="nav nav-pills" role="tablist">
    ...
    <li><a href="login">login</a></li>
    ...
  </ul>
</div>

此链接指向的 "/login" 端点由 Spring Security 处理,如果用户未经身份验证,它将导致重定向到授权服务器。

我们还可以删除 "navigation" 控制器中 login() 函数的定义,以及 Angular 配置中的 "/login" 路由,这简化了实现。

angular.module('hello', [ 'ngRoute' ]).config(function($routeProvider) {

  $routeProvider.when('/', {
    templateUrl : 'home.html',
    controller : 'home'
  }).otherwise('/');

}). // ...
.controller('navigation',

function($rootScope, $scope, $http, $location, $route) {

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

  $scope.credentials = {};

  $scope.logout = function() {
    $http.post('logout', {}).success(function() {
      $rootScope.authenticated = false;
      $location.path("/");
    }).error(function(data) {
      $rootScope.authenticated = false;
    });
  }

});

它是如何工作的?

现在一起运行所有服务器,并在浏览器中访问 UI,网址为 https://127.0.0.1:8080。单击“登录”链接,您将被重定向到授权服务器以进行身份验证(HTTP Basic 弹出窗口)并批准令牌授予(白标 HTML),然后重定向到 UI 中的主页,并使用与我们对 UI 进行身份验证相同的令牌从 OAuth2 资源服务器获取问候语。

如果您使用一些开发人员工具(通常 F12 会打开它,在 Chrome 中默认有效,在 Firefox 中需要插件),则可以在浏览器中查看浏览器和后端之间的交互。以下是摘要。

动词 路径 状态 响应
GET / 200 index.html
GET /css/angular-bootstrap.css 200 Twitter bootstrap CSS
GET /js/angular-bootstrap.js 200 Bootstrap 和 Angular JS
GET /js/hello.js 200 应用程序逻辑
GET /home.html 200 主页的 HTML 部分
GET /user 302 重定向到登录页面
GET /login 302 重定向到授权服务器
GET (uaa)/oauth/authorize 401 (忽略)
GET /resource 302 重定向到登录页面
GET /login 302 重定向到授权服务器
GET (uaa)/oauth/authorize 401 (忽略)
GET /login 302 重定向到授权服务器
GET (uaa)/oauth/authorize 200 此处发生 HTTP Basic 身份验证
POST (uaa)/oauth/authorize 302 用户批准授予,重定向到 /login
GET /login 302 重定向到主页
GET /user 200 (代理) JSON 经过身份验证的用户
GET /home.html 200 主页的 HTML 部分
GET /resource 200 (代理) JSON 问候语

以 (uaa) 为前缀的请求是发送到授权服务器的。标记为“忽略”的响应是 Angular 在 XHR 调用中接收到的响应,由于我们没有处理该数据,因此它们会被丢弃。在 "/user" 资源的情况下,我们确实会查找经过身份验证的用户,但由于在第一次调用中不存在该用户,因此该响应会被丢弃。

在 UI 的 "/trace" 端点中(向下滚动到底部),您将看到代理到 "/user" 和 "/resource" 的后端请求,以及 remote:true 和 Bearer 令牌(而不是像 第四部分 中那样使用 cookie)用于身份验证。Spring Cloud Security 为我们处理了这一点:通过识别我们具有 @EnableOAuth2Sso@EnableZuulProxy,它已经确定(默认情况下)我们希望将令牌中继到代理的后端。

注意:与之前的文章一样,尝试对 "/trace" 使用不同的浏览器,以避免身份验证交叉(例如,如果使用 Chrome 测试 UI,则使用 Firefox)。

注销体验

如果您点击“注销”链接,您会看到主页发生了变化(问候语不再显示),因此用户不再与UI服务器进行身份验证。但是,如果再次点击“登录”,您实际上**不需要**再次经过授权服务器中的身份验证和批准流程(因为您尚未注销该服务器)。关于这是否是一种理想的用户体验,人们的意见会有所分歧,并且这是一个臭名昭著的棘手问题(单点注销:Science Direct文章Shibboleth文档)。理想的用户体验在技术上可能不可行,而且您有时也必须怀疑用户是否真的想要他们所说的东西。“我希望‘注销’能将我注销”听起来很简单,但显而易见的回应是,“注销什么?您是想注销由此SSO服务器控制的**所有**系统,还是只想注销您点击“注销”链接的那个系统?”我们在这里没有空间更广泛地讨论这个主题,但它确实值得更多关注。如果您有兴趣,可以在Open ID Connect规范中找到一些关于原则的讨论以及一些(相当不吸引人的)关于实现的想法。

结论

这几乎是我们对Spring Security和Angular JS栈的浅层之旅的结束。我们现在拥有一个良好的架构,在三个独立的组件(UI/API网关、资源服务器和授权服务器/令牌授予者)中具有清晰的职责。所有层级中的非业务代码数量现在最少,并且很容易看出在哪里扩展和改进实现以添加更多业务逻辑。接下来的步骤将是整理授权服务器中的UI,并且可能添加更多测试,包括JavaScript客户端的测试。另一个有趣的任务是提取所有样板代码并将其放入一个库(例如,“spring-security-angular”)中,该库包含Spring Security和Spring Session自动配置以及Angular部分中导航控制器的某些webjars资源。阅读本系列文章后,任何希望了解Angular JS或Spring Security内部工作原理的人可能会感到失望,但如果您想了解它们如何很好地协同工作以及少量配置如何发挥巨大作用,那么希望您能获得良好的体验。Spring Cloud是新的,这些示例在编写时需要快照,但现在有候选版本可用,并且即将发布GA版本,因此请查看并发送一些反馈通过Githubgitter.im

本系列的下一篇文章是关于访问决策(超越身份验证)的,并且使用同一个代理后面的多个UI应用程序。

附录:授权服务器的Bootstrap UI和JWT令牌

您可以在Github中的源代码中找到此应用程序的另一个版本,该版本具有一个漂亮的登录页面和用户批准页面,其实现方式类似于我们在第二部分中实现登录页面的方式。它还使用JWT来编码令牌,因此,资源服务器无需使用“/user”端点,而是可以从令牌本身中提取足够的信息来进行简单的身份验证。浏览器客户端仍然使用它,通过UI服务器进行代理,以便它可以确定用户是否已进行身份验证(与真实应用程序中对资源服务器的调用次数相比,它不需要经常执行此操作)。

获取Spring时事通讯

与Spring时事通讯保持联系

订阅

领先一步

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

了解更多

获取支持

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

了解更多

即将举行的活动

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

查看全部