使用 OAuth2 实现 SSO:Angular JS 与 Spring Security 第五部分

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

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

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

提醒:如果您正在通过示例应用程序学习本文,请务必清除浏览器的 Cookie 和 HTTP Basic 凭据缓存。在 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”,包含一个 secret 和一些授权类型,包括“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 Basic 认证的保护。要启动授权码令牌授予,您需要访问授权端点,例如在 http://localhost: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...”),由服务器中的内存令牌存储支持。我们还获得了一个刷新令牌(refresh token),可以在当前访问令牌过期时使用它来获取新的访问令牌。

注意:由于我们允许“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>

然后从主应用程序类中移除 session 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”头部,表示它需要一个 bearer token。我们将添加少量外部配置(在“application.properties”中),以允许资源服务器解码获得的令牌并认证用户

...
spring.oauth2.resource.userInfoUri: http://localhost:9999/uaa/user

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

注意:userInfoUri 绝不是将资源服务器与令牌解码方式连接起来的唯一方法。事实上,它有点像一个最低公分母(并且不是规范的一部分),但通常 OAuth2 提供者(如 Facebook、Cloud Foundry、Github)会提供它,也有其他选择。例如,您可以将用户认证信息编码在令牌本身中(例如使用 JWT),或者使用共享后端存储。CloudFoundry 中还有一个 /token_info 端点,它提供比 user info 端点更详细的信息,但需要更彻底的认证。不同的选项(自然地)提供不同的好处和权衡,但对这些的全面讨论超出了本文的范围。

实现用户端点

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

@SpringBootApplication
@RestController
@EnableResourceServer
public class AuthserverApplication {

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

  ...

}

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

有了这个端点,我们可以测试它和 greeting 资源,因为它们现在都接受由授权服务器创建的 bearer token

$ 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。

完成上述步骤后,我们也可以移除 session filter 和“/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 Gateway,我们可以在 YAML 中声明路由映射。因此,“/user”端点可以被代理到授权服务器

zuul:
  routes:
    resource:
      path: /resource/**
      url: http://localhost:9000
    user:
      path: /user/**
      url: http://localhost: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
  }

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

@EnableOAuth2Sso 注解还需要一些强制性的外部配置属性,以便能够与正确的授权服务器联系并进行认证。因此,我们需要在 application.yml 中加入这些配置

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

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

客户端配置

在前端的 UI 应用中,我们还需要进行一些小修改,以便触发重定向到授权服务器。第一个修改是在“index.html”的导航栏中,其中“login”链接从一个 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,地址是 http://localhost:8080。点击“login”链接,您将被重定向到授权服务器进行认证(HTTP Basic 弹窗)并批准令牌授予(白标 HTML 页面),然后重定向回 UI 的主页,greeting 信息将从 OAuth2 资源服务器获取,使用的令牌与认证 UI 时使用的令牌相同。

如果您使用开发者工具(通常按 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 greeting

前缀为 (uaa) 的请求发送到授权服务器。标记为“忽略”的响应是 Angular 在 XHR 调用中接收到的响应,由于我们没有处理这些数据,所以它们被丢弃了。在请求“/user”资源时,我们确实查找认证用户,但由于在第一次调用中不存在,该响应被丢弃。

在 UI 的“/trace”端点(向下滚动到底部),您将看到代理到后端“/user”和“/resource”的请求,其中带有 remote:true 并使用 bearer token 而非 Cookie(就像在第四部分那样)进行认证。Spring Cloud Security 已经为我们处理了这一切:通过识别我们使用了 @EnableOAuth2Sso@EnableZuulProxy,它默认推断出我们想要将 token 中继到代理的后端服务。

注意:与前几篇文章一样,请尝试使用不同的浏览器来访问“/trace”,这样可以避免认证交叉的问题(例如,如果您使用 Chrome 测试 UI,则使用 Firefox 访问“/trace”)。

注销体验

如果您点击“logout”链接,您会看到主页发生变化(不再显示 greeting),因此用户已不再在 UI 服务器上认证。但是,如果您再次点击“login”,实际上您无需再次通过授权服务器的认证和批准流程(因为您还没有从那里注销)。关于这是否是理想的用户体验,人们意见不一,这是一个出了名的棘手问题(单点注销:Science Direct 文章Shibboleth 文档)。理想的用户体验可能在技术上不可行,有时您也需要怀疑用户是否真的想要他们所说的。说“我想‘logout’把我登出”听起来很简单,但显而易见的回答是:“从哪里登出?您是想从这个 SSO 服务器控制的所有系统中登出,还是仅仅从您点击‘logout’链接的那个系统中登出?”我们在这里没有更多的空间来更广泛地讨论这个话题,但它确实值得更多关注。如果您感兴趣,可以在Open ID Connect 规范中找到一些关于原则和一些(相当不吸引人的)实现思路的讨论。

总结

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

系列的下一篇文章将讨论访问决策(超越认证)以及如何在同一个代理背后使用多个 UI 应用。

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

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

获取 Spring 新闻邮件

订阅 Spring 新闻邮件,保持联系

订阅

领先一步

VMware 提供培训和认证,助力您快速提升。

了解更多

获取支持

Tanzu Spring 提供对 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,一个简单订阅即可获得。

了解更多

近期活动

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

查看全部